diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..2775b3e902 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,70 @@ +name: Bug Report +description: File a bug report +title: "[Bug]: " +labels: ["bug", "triage me"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true + - type: checkboxes + attributes: + label: Is there a StackOverflow question about this issue? + description: Please search [StackOverflow](https://stackoverflow.com/questions/tagged/android-jetpack-compose) if an issue with an answer already exists for the bug you encountered. + options: + - label: I have searched StackOverflow + required: true + - type: checkboxes + attributes: + label: Is this an issue related to one of the samples? + description: Please confirm that this is an issue related to this sample repo. If this is a bug related to Compose, file an issue on the Compose [issue tracker](https://issuetracker.google.com/issues/new?component=612128) instead. + options: + - label: Yes, this is a specific issue related to this samples repo. + required: true + - type: dropdown + id: sample-app + attributes: + label: Sample app + description: What sample app did you encounter a bug on? + options: + - Crane + - JetNews + - Jetcaster + - Jetchat + - Jetsnack + - Jetsurvey + - Owl + - Reply + - Other (bug not related to sample app) + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logcat output + description: Please copy and paste any relevant logcat output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..415259dd1d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,70 @@ +name: Feature request +description: File a feature request +title: "[FR]: " +labels: ["enhancement", "triage me"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for this feature request. + options: + - label: I have searched the existing issues + required: true + - type: checkboxes + attributes: + label: Is this a feature request for one of the samples? + description: Please confirm that this is a feature request related to this samples repo. If this is a request related to Compose, file a feature request on the Compose [issue tracker](https://issuetracker.google.com/issues/new?component=612128) instead. + options: + - label: Yes, this is a specific request related to this samples repo. + required: true + - type: dropdown + id: sample-app + attributes: + label: Sample app + description: Which sample app does this request apply to? + options: + - Crane + - JetNews + - Jetcaster + - Jetchat + - Jetsnack + - Jetsurvey + - Owl + - Reply + - Other (bug not related to sample app) + validations: + required: true + - type: textarea + id: describe-problem + attributes: + label: Describe the problem + description: Is your feature request related to a problem? Please describe. + placeholder: I'm always frustrated when... + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe the solution + description: Please describe the solution you'd like. A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000000..ab8c4d0afb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,11 @@ +--- +name: Pull request +about: Create a pull request +label: 'triage me' +--- +Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: +- [ ] Make sure to open a GitHub issue as a bug/feature request before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea +- [ ] Ensure the tests and linter pass +- [ ] Appropriate docs were updated (if necessary) + +Fixes # 🦕 diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml new file mode 100644 index 0000000000..0171c48bbc --- /dev/null +++ b/.github/blunderbuss.yml @@ -0,0 +1,3 @@ +assign_issues: + - android/compose-devrel + diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties index 5dd327d5c8..9ba9603f34 100644 --- a/.github/ci-gradle.properties +++ b/.github/ci-gradle.properties @@ -21,3 +21,6 @@ org.gradle.workers.max=2 kotlin.incremental=false kotlin.compiler.execution.strategy=in-process + +# Controls KotlinOptions.allWarningsAsErrors. This is used in CI and can be set in local properties. +warningsAsErrors=true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..0d08e261a2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000..824f9f7ebf --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,61 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 60 + +# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. +daysUntilClose: 7 + +# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) +onlyLabels: [] + +# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable +exemptLabels: + - pinned + - security + - "[Status] Maybe Later" + +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: false + +# Set to true to ignore issues in a milestone (defaults to false) +exemptMilestones: false + +# Set to true to ignore issues with an assignee (defaults to false) +exemptAssignees: false + +# Label to use when marking as stale +staleLabel: stale + +# Comment to post when marking as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had any + recent activity. Please comment here if it is still valid so that we can reprioritize it. Thank you + for your contributions. + +# Comment to post when removing the stale label. +# unmarkComment: > +# Your comment here. + +# Comment to post when closing a stale Issue or Pull Request. +# closeComment: > +# Your comment here. + +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 30 + +# Limit to only `issues` or `pulls` +# only: issues + +# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': +# pulls: +# daysUntilStale: 30 +# markComment: > +# This pull request has been automatically marked as stale because it has not had +# recent activity. It will be closed if no further activity occurs. Thank you +# for your contributions. + +# issues: +# exemptLabels: +# - confirmed diff --git a/.github/workflows/Crane.yaml b/.github/workflows/Crane.yaml deleted file mode 100644 index 0cbd39a982..0000000000 --- a/.github/workflows/Crane.yaml +++ /dev/null @@ -1,107 +0,0 @@ -name: Crane - -on: - push: - branches: - - main - paths: - - 'Crane/**' - pull_request: - paths: - - 'Crane/**' - -env: - SAMPLE_PATH: Crane - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports - - test: - needs: build - runs-on: macOS-latest # enables hardware acceleration in the virtual machine - timeout-minutes: 30 - strategy: - matrix: - api-level: [23, 26, 29] - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - target: google_apis - arch: x86 - disable-animations: true - script: ./gradlew connectedCheck --stacktrace - working-directory: ${{ env.SAMPLE_PATH }} - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: test-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/JetLagged.yaml b/.github/workflows/JetLagged.yaml new file mode 100644 index 0000000000..6c38e16bf6 --- /dev/null +++ b/.github/workflows/JetLagged.yaml @@ -0,0 +1,78 @@ +name: JetLagged + +on: + push: + branches: + - main + paths: + - '.github/workflows/JetLagged.yaml' + - 'JetLagged/**' + pull_request: + paths: + - '.github/workflows/JetLagged.yaml' + - 'JetLagged/**' + workflow_dispatch: + +env: + SAMPLE_PATH: JetLagged + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} +jobs: + build: + uses: ./.github/workflows/build-sample.yml + with: + name: JetLagged + path: JetLagged + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} + + test: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + matrix: + api-level: [23, 26, 29] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: 'zulu' + + - name: Generate cache key + run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt + + - uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches/modules-* + ~/.gradle/caches/jars-* + ~/.gradle/caches/build-cache-* + key: gradle-${{ hashFiles('checksum.txt') }} + + - name: Run instrumentation tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86 + disable-animations: true + script: ./gradlew connectedCheck --stacktrace + working-directory: ${{ env.SAMPLE_PATH }} + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-reports-jetlagged-${{ matrix.api-level }} + path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/JetNews.yaml b/.github/workflows/JetNews.yaml index a7a16e80c4..6fbdd6f102 100644 --- a/.github/workflows/JetNews.yaml +++ b/.github/workflows/JetNews.yaml @@ -5,62 +5,33 @@ on: branches: - main paths: + - '.github/workflows/JetNews.yaml' - 'JetNews/**' pull_request: paths: + - '.github/workflows/JetNews.yaml' - 'JetNews/**' + workflow_dispatch: env: SAMPLE_PATH: JetNews - + compose_store_password: ${{ secrets.compose_store_password }} + compose_key_alias: ${{ secrets.compose_key_alias }} + compose_key_password: ${{ secrets.compose_key_password }} jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports - - test: + uses: ./.github/workflows/build-sample.yml + with: + name: JetNews + path: JetNews + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} + + androidTest: needs: build - runs-on: macOS-latest # enables hardware acceleration in the virtual machine + runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: @@ -68,27 +39,31 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 - + uses: actions/checkout@v6 + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: - java-version: 11 + java-version: 17 + distribution: 'zulu' - name: Generate cache key run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - uses: actions/cache@v2 + - uses: actions/cache@v5 with: path: | ~/.gradle/caches/modules-* ~/.gradle/caches/jars-* ~/.gradle/caches/build-cache-* key: gradle-${{ hashFiles('checksum.txt') }} - - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@v2 with: @@ -100,7 +75,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v6 with: - name: test-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports + name: test-reports-jetnews-${{ matrix.api-level }} + path: ${{ env.SAMPLE_PATH }}/app/build/reports/androidTests diff --git a/.github/workflows/Jetcaster.yaml b/.github/workflows/Jetcaster.yaml index f9f8accc20..44c15fe1ce 100644 --- a/.github/workflows/Jetcaster.yaml +++ b/.github/workflows/Jetcaster.yaml @@ -5,55 +5,22 @@ on: branches: - main paths: + - '.github/workflows/Jetcaster.yaml' - 'Jetcaster/**' pull_request: paths: + - '.github/workflows/Jetcaster.yaml' - 'Jetcaster/**' - -env: - SAMPLE_PATH: Jetcaster + workflow_dispatch: jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports + uses: ./.github/workflows/build-sample.yml + with: + name: Jetcaster + path: Jetcaster + module: mobile + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} diff --git a/.github/workflows/Jetchat.yaml b/.github/workflows/Jetchat.yaml index a8fd3fde50..51818d2316 100644 --- a/.github/workflows/Jetchat.yaml +++ b/.github/workflows/Jetchat.yaml @@ -5,62 +5,32 @@ on: branches: - main paths: + - '.github/workflows/Jetchat.yaml' - 'Jetchat/**' pull_request: paths: + - '.github/workflows/Jetchat.yaml' - 'Jetchat/**' + workflow_dispatch: env: SAMPLE_PATH: Jetchat - + compose_store_password: ${{ secrets.compose_store_password }} + compose_key_alias: ${{ secrets.compose_key_alias }} + compose_key_password: ${{ secrets.compose_key_password }} jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports - + uses: ./.github/workflows/build-sample.yml + with: + name: Jetchat + path: Jetchat + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} test: needs: build - runs-on: macOS-latest # enables hardware acceleration in the virtual machine + runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: @@ -68,20 +38,21 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: - java-version: 11 + java-version: 17 + distribution: 'zulu' - name: Generate cache key run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - uses: actions/cache@v2 + - uses: actions/cache@v5 with: path: | ~/.gradle/caches/modules-* @@ -100,7 +71,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v6 with: - name: test-reports + name: test-reports-jetchat-${{ matrix.api-level }} path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/Jetsnack.yaml b/.github/workflows/Jetsnack.yaml index 7dc773c603..1962c2c789 100644 --- a/.github/workflows/Jetsnack.yaml +++ b/.github/workflows/Jetsnack.yaml @@ -5,55 +5,21 @@ on: branches: - main paths: + - '.github/workflows/Jetsnack.yaml' - 'Jetsnack/**' pull_request: paths: + - '.github/workflows/Jetsnack.yaml' - 'Jetsnack/**' - -env: - SAMPLE_PATH: Jetsnack + workflow_dispatch: jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports + uses: ./.github/workflows/build-sample.yml + with: + name: Jetsnack + path: Jetsnack + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} diff --git a/.github/workflows/Jetsurvey.yaml b/.github/workflows/Jetsurvey.yaml deleted file mode 100644 index 2b5e6abc12..0000000000 --- a/.github/workflows/Jetsurvey.yaml +++ /dev/null @@ -1,59 +0,0 @@ -name: Jetsurvey - -on: - push: - branches: - - main - paths: - - 'Jetsurvey/**' - pull_request: - paths: - - 'Jetsurvey/**' - -env: - SAMPLE_PATH: Jetsurvey - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/Owl.yaml b/.github/workflows/Owl.yaml deleted file mode 100644 index 151fa85af7..0000000000 --- a/.github/workflows/Owl.yaml +++ /dev/null @@ -1,106 +0,0 @@ -name: Owl - -on: - push: - branches: - - main - paths: - - 'Owl/**' - pull_request: - paths: - - 'Owl/**' - -env: - SAMPLE_PATH: Owl - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports - - test: - needs: build - runs-on: macOS-latest # enables hardware acceleration in the virtual machine - timeout-minutes: 30 - strategy: - matrix: - api-level: [23, 26, 29] - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - arch: x86 - disable-animations: true - script: ./gradlew connectedCheck --stacktrace - working-directory: ${{ env.SAMPLE_PATH }} - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: test-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/Rally.yaml b/.github/workflows/Rally.yaml deleted file mode 100644 index 6630eda04f..0000000000 --- a/.github/workflows/Rally.yaml +++ /dev/null @@ -1,59 +0,0 @@ -name: Rally - -on: - push: - branches: - - main - paths: - - 'Rally/**' - pull_request: - paths: - - 'Rally/**' - -env: - SAMPLE_PATH: Rally - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index f0aa853764..04499c3dca 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -1,10 +1,13 @@ -name: GitHub Release with APKs - +name: Build all projects on new version tag on: + workflow_dispatch: push: tags: - 'v*' - +env: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} jobs: build: runs-on: ubuntu-latest @@ -12,106 +15,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: - java-version: 11 + java-version: 17 + distribution: 'zulu' - name: Build all projects - run: ./scripts/gradlew_recursive.sh assembleDebug - - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - draft: true - prerelease: false - - - name: Upload Crane - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Crane/app/build/outputs/apk/debug/app-debug.apk - asset_name: crane-debug.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload Owl - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Owl/app/build/outputs/apk/debug/app-debug.apk - asset_name: owl-debug.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload Rally - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Rally/app/build/outputs/apk/debug/app-debug.apk - asset_name: rally-debug.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload Jetcaster - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Jetcaster/app/build/outputs/apk/debug/app-debug.apk - asset_name: jetcaster-debug.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload Jetchat - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Jetchat/app/build/outputs/apk/debug/app-debug.apk - asset_name: jetchat-debug.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload Jetnews - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: JetNews/app/build/outputs/apk/debug/app-debug.apk - asset_name: jetnews-debug.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload Jetsnack - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Jetsnack/app/build/outputs/apk/debug/app-debug.apk - asset_name: jetsnack-debug.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload Jetsurvey - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Jetsurvey/app/build/outputs/apk/debug/app-debug.apk - asset_name: jetsurvey-debug.apk - asset_content_type: application/vnd.android.package-archive + run: ./scripts/gradlew_recursive.sh assembleDebug \ No newline at end of file diff --git a/.github/workflows/Reply.yaml b/.github/workflows/Reply.yaml new file mode 100644 index 0000000000..2168aa9cb0 --- /dev/null +++ b/.github/workflows/Reply.yaml @@ -0,0 +1,78 @@ +name: Reply + +on: + push: + branches: + - main + paths: + - '.github/workflows/Reply.yaml' + - 'Reply/**' + pull_request: + paths: + - '.github/workflows/Reply.yaml' + - 'Reply/**' + workflow_dispatch: + +env: + SAMPLE_PATH: Reply + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} +jobs: + build: + uses: ./.github/workflows/build-sample.yml + with: + name: Reply + path: Reply + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} + + test: + needs: build + runs-on: ubuntu-latest # enables hardware acceleration in the virtual machine + timeout-minutes: 30 + strategy: + matrix: + api-level: [23, 26, 29] + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: 'zulu' + + - name: Generate cache key + run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt + + - uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches/modules-* + ~/.gradle/caches/jars-* + ~/.gradle/caches/build-cache-* + key: gradle-${{ hashFiles('checksum.txt') }} + + - name: Run instrumentation tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86 + disable-animations: true + script: ./gradlew connectedCheck --stacktrace + working-directory: ${{ env.SAMPLE_PATH }} + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-reports-reply-${{ matrix.api-level }} + path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/build-sample.yml b/.github/workflows/build-sample.yml new file mode 100644 index 0000000000..9fb85442dc --- /dev/null +++ b/.github/workflows/build-sample.yml @@ -0,0 +1,97 @@ +name: Build and Test Sample + +on: + workflow_call: + inputs: + name: + required: true + type: string + path: + required: true + type: string + module: + default: "app" + type: string + secrets: + compose_store_password: + description: 'password for the keystore' + required: true + compose_key_alias: + description: 'alias for the keystore' + required: true + compose_key_password: + description: 'password for the key' + required: true +concurrency: + group: ${{ inputs.name }}-build-${{ github.ref }} + cancel-in-progress: true +env: + compose_store_password: ${{ secrets.compose_store_password }} + compose_key_alias: ${{ secrets.compose_key_alias }} + compose_key_password: ${{ secrets.compose_key_password }} +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # https://github.com/diffplug/spotless/issues/710 + # Check out full history for Spotless to ensure ratchetFrom can find the ratchet version + fetch-depth: 0 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: 'zulu' + + - name: Generate cache key + run: ./scripts/checksum.sh ${INPUTS_PATH} checksum.txt + env: + INPUTS_PATH: ${{ inputs.path }} + + - uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches/modules-* + ~/.gradle/caches/jars-* + ~/.gradle/caches/build-cache-* + key: gradle-${{ hashFiles('checksum.txt') }} + + - name: Check formatting + working-directory: ${{ inputs.path }} + run: ./gradlew spotlessCheck --stacktrace + + - name: Check lint + working-directory: ${{ inputs.path }} + run: ./gradlew lintDebug --stacktrace + + - name: Build debug + working-directory: ${{ inputs.path }} + run: ./gradlew assembleDebug --stacktrace + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run local tests + working-directory: ${{ inputs.path }} + run: ./gradlew testDebug --stacktrace + + - name: Upload build outputs (APKs) + uses: actions/upload-artifact@v6 + with: + name: build-outputs + path: ${{ inputs.path }}/${{ inputs.module }}/build/outputs + + - name: Upload build reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: build-reports + path: ${{ inputs.path }}/${{ inputs.module }}/build/reports diff --git a/.github/workflows/copy-branch.yml b/.github/workflows/copy-branch.yml deleted file mode 100644 index f8f8572d9a..0000000000 --- a/.github/workflows/copy-branch.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Duplicates default main branch to the old master branch - -name: Duplicates main to old master branch - -# Controls when the action will run. Triggers the workflow on push or pull request -# events but only for the main branch -on: - push: - branches: [ main ] - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - # This workflow contains a single job called "copy-branch" - copy-branch: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it, - # but specifies master branch (old default). - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - ref: master - - - run: | - git config user.name github-actions - git config user.email github-actions@github.com - git merge origin/main - git push diff --git a/.github/workflows/default-check.yml b/.github/workflows/default-check.yml new file mode 100644 index 0000000000..7d524a4da7 --- /dev/null +++ b/.github/workflows/default-check.yml @@ -0,0 +1,32 @@ +name: Changes to project files + +on: + push: + branches: + - main + paths-ignore: + - 'Jetcaster/**' + - 'Jetchat/**' + - 'JetLagged/**' + - 'JetNews/**' + - 'Jetsnack/**' + - 'Reply/**' + pull_request: + paths-ignore: + - 'Jetcaster/**' + - 'Jetchat/**' + - 'JetLagged/**' + - 'JetNews/**' + - 'Jetsnack/**' + - 'Reply/**' + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run a simple check + run: echo "Running a simple check on files outside sample directories" diff --git a/.github/workflows/update_deps.yml b/.github/workflows/update_deps.yml new file mode 100644 index 0000000000..f2c8a0f713 --- /dev/null +++ b/.github/workflows/update_deps.yml @@ -0,0 +1,38 @@ +name: Update Versions / Dependencies + +on: + workflow_dispatch: +env: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + - name: set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: 'zulu' + cache: gradle + + - name: Update dependencies + run: ./scripts/updateDeps.sh + - name: Create pull request + id: cpr + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.PAT }} + commit-message: 🤖 Update Dependencies + committer: compose-devrel-github-bot + author: compose-devrel-github-bot + signoff: false + branch: bot-update-deps + delete-branch: true + title: '🤖 Update Dependencies' + body: Updated depedencies + reviewers: ${{ github.actor }} diff --git a/.gitignore b/.gitignore index 3a2358d361..ddccb823a4 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ proguard-project.txt # Android Studio/IDEA *.iml -.idea \ No newline at end of file +.idea +.kotlin/ diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000..74f07af571 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @android/devrel-compose diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..f8b12cb550 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,63 @@ +# Google Open Source Community Guidelines + +At Google, we recognize and celebrate the creativity and collaboration of open +source contributors and the diversity of skills, experiences, cultures, and +opinions they bring to the projects and communities they participate in. + +Every one of Google's open source projects and communities are inclusive +environments, based on treating all individuals respectfully, regardless of +gender identity and expression, sexual orientation, disabilities, +neurodiversity, physical appearance, body size, ethnicity, nationality, race, +age, religion, or similar personal characteristic. + +We value diverse opinions, but we value respectful behavior more. + +Respectful behavior includes: + +* Being considerate, kind, constructive, and helpful. +* Not engaging in demeaning, discriminatory, harassing, hateful, sexualized, or + physically threatening behavior, speech, and imagery. +* Not engaging in unwanted physical contact. + +Some Google open source projects [may adopt][] an explicit project code of +conduct, which may have additional detailed expectations for participants. Most +of those projects will use our [modified Contributor Covenant][]. + +[may adopt]: https://opensource.google/docs/releasing/preparing/#conduct +[modified Contributor Covenant]: https://opensource.google/docs/releasing/template/CODE_OF_CONDUCT/ + +## Resolve peacefully + +We do not believe that all conflict is necessarily bad; healthy debate and +disagreement often yields positive results. However, it is never okay to be +disrespectful. + +If you see someone behaving disrespectfully, you are encouraged to address the +behavior directly with those involved. Many issues can be resolved quickly and +easily, and this gives people more control over the outcome of their dispute. +If you are unable to resolve the matter for any reason, or if the behavior is +threatening or harassing, report it. We are dedicated to providing an +environment where participants feel welcome and safe. + +## Reporting problems + +Some Google open source projects may adopt a project-specific code of conduct. +In those cases, a Google employee will be identified as the Project Steward, +who will receive and handle reports of code of conduct violations. In the event +that a project hasn’t identified a Project Steward, you can report problems by +emailing opensource@google.com. + +We will investigate every complaint, but you may not receive a direct response. +We will use our discretion in determining when and how to follow up on reported +incidents, which may range from not taking action to permanent expulsion from +the project and project-sponsored spaces. We will notify the accused of the +report and provide them an opportunity to discuss it before any action is +taken. The identity of the reporter will be omitted from the details of the +report supplied to the accused. In potentially harmful situations, such as +ongoing harassment or threats to anyone's safety, we may take action without +notice. + +*This document was adapted from the [IndieWeb Code of Conduct][] and can also +be found at .* + +[IndieWeb Code of Conduct]: https://indieweb.org/code-of-conduct diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3196023998..f23eb506de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ accept your pull requests. ## Contributing A Patch -All development is done on the latest `dev_XX` branch. You should base any changes from this branch. +All development is done on the `main` branch. You should base any changes from this branch. 1. Submit an issue describing your proposed change to the repo in question. 1. The repo owner will respond to your issue promptly. @@ -30,4 +30,4 @@ All development is done on the latest `dev_XX` branch. You should base any chang [Kotlin Style Guide](https://android.github.io/kotlin-guides/style.html) for the recommended coding standards for this organization. 1. Ensure that your code has an appropriate set of unit tests which all pass. -1. Submit a pull request targeting the latest `dev_XX` branch. +1. Submit a pull request targeting the `main` branch. diff --git a/Crane/.gitignore b/Crane/.gitignore deleted file mode 100644 index 73652c1603..0000000000 --- a/Crane/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea -.DS_Store -/build -/captures -.externalNativeBuild -/studio/ diff --git a/Crane/.google/packaging.yaml b/Crane/.google/packaging.yaml deleted file mode 100644 index 9d138a236b..0000000000 --- a/Crane/.google/packaging.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (C) 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# GOOGLE SAMPLE PACKAGING DATA -# -# This file is used by Google as part of our samples packaging process. -# End users may safely ignore this file. It has no relevance to other systems. ---- -status: PUBLISHED -technologies: [Android] -categories: [Compose] -languages: [Kotlin] -solutions: [Mobile] -github: android/compose-samples -level: INTERMEDIATE -apiRefs: - - android:androidx.compose.Composable -license: apache2 diff --git a/Crane/README.md b/Crane/README.md deleted file mode 100644 index 2c7edef9a3..0000000000 --- a/Crane/README.md +++ /dev/null @@ -1,119 +0,0 @@ -# Crane sample - -[Crane](https://material.io/design/material-studies/crane.html) is a travel app part of the Material -Studies built with [Jetpack Compose](https://developer.android.com/jetpack/compose). -The goal of the sample is to showcase Material components, draggable UI elements, Android Views -inside Compose, and UI state handling. - -To try out this sample app, you need to use the latest Canary version of Android Studio 4.2. -You can clone this repository or import the -project from Android Studio following the steps -[here](https://developer.android.com/jetpack/compose/setup#sample). - -## Screenshots - - - -## Features - -This sample contains 4 screens: -- __Landing__ [screen][landing] that fades out after 2 seconds then slides the main content in from -the bottom of the screen. -- __Home__ [screen][home] where you can explore flights, hotels, and restaurants specifying -the number of people. - - Clicking on the number of people refreshes the destinations. - - The [backdrop](https://material.io/components/backdrop) is draggable and can pin to the top of - the screen, just under the search criteria, and to the bottom. Implemented [here][backdrop]. - - Destination's images are retrieved using the [coil-accompanist][coil-accompanist] library. -- __Calendar__ [screen][calendar]. Tapping on __Select Dates__ takes you to a calendar built -completely from scratch. It makes a heavy usage of Compose's state APIs. -- Destination's __Details__ [screen][details]. When tapping on a destination, a new screen -implemented using a different Activity will be displayed. In there, you can see the a `MapView` -embedded in Compose and Compose buttons updating the Android View. Notice how you can also -interact with the `MapView` seamlessly. - -## Hilt - -Crane uses [Hilt][hilt] to manage its dependencies. Hilt's ViewModel (with the -`@HiltViewModel` annotation) works perfectly with Compose's ViewModel integration (`viewModel()` -composable function) as you can see in the following snippet of code. `viewModel()` will -automatically use the factory that Hilt creates for the ViewModel: - -``` -@HiltViewModel -class MainViewModel @Inject constructor( - private val destinationsRepository: DestinationsRepository, - @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, - datesRepository: DatesRepository -) : ViewModel() { ... } - -@Composable -fun CraneHomeContent(...) { - val viewModel: MainViewModel = viewModel() - ... -} -``` - -Disclaimer: Passing dependencies to a ViewModel which are not available at compile time (which is -sometimes called _assisted injection_) doesn't work as you might expect using `viewModel()`. -Compose's ViewModel integration cannot currently scope a ViewModel to a given composable. Instead -it is always scoped to the host Activity or Fragment. This means that calling `viewModel()` with -different factories in the same host Activity/Fragment don't have the desired effect; the _first_ -factory will be always used. - -This is the case of the [DetailsViewModel](detailsViewModel), which takes the name of -a `City` as a parameter to load the required information for the screen. However, the above isn't a -problem in this sample, since `DetailsScreen` is always used in it's own newly launched Activity. - -## Google Maps SDK - -To get the MapView working, you need to get an API key as -the [documentation says](https://developers.google.com/maps/documentation/android-sdk/get-api-key), -and include it in the `local.properties` file as follows: - -``` -google.maps.key={insert_your_api_key_here} -``` - -## Data - -The data is hardcoded in the _CraneData_ [file][data] and exposed to the UI using the -[MainViewModel][mainViewModel]. Image resources are retrieved from -[Unsplash](https://unsplash.com/). - -## Testing - -Crane has Compose-only tests (e.g. [HomeTest][homeTest]) but also tests covering Compose and the -view-based system (e.g. [DetailsActivityTest][detailsTest]). The latter uses the `onActivity` -method of the `ActivityScenarioRule` to access information from the `MapView`. - -## License - -``` -Copyright 2020 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` - -[landing]: app/src/main/java/androidx/compose/samples/crane/home/LandingScreen.kt -[home]: app/src/main/java/androidx/compose/samples/crane/home/CraneHome.kt -[backdrop]: app/src/main/java/androidx/compose/samples/crane/ui/BackdropFrontLayer.kt -[calendar]: app/src/main/java/androidx/compose/samples/crane/calendar/Calendar.kt -[details]: app/src/main/java/androidx/compose/samples/crane/details/DetailsActivity.kt -[data]: app/src/main/java/androidx/compose/samples/crane/data/CraneData.kt -[mainViewModel]: app/src/main/java/androidx/compose/samples/crane/home/MainViewModel.kt -[detailsViewModel]: app/src/main/java/androidx/compose/samples/crane/details/DetailsViewModel.kt -[homeTest]: app/src/androidTest/java/androidx/compose/samples/crane/home/HomeTest.kt -[detailsTest]: app/src/androidTest/java/androidx/compose/samples/crane/details/DetailsActivityTest.kt -[coil-accompanist]: https://google.github.io/accompanist/coil/ -[hilt]: https://d.android.com/hilt diff --git a/Crane/app/build.gradle b/Crane/app/build.gradle deleted file mode 100644 index 6a80031484..0000000000 --- a/Crane/app/build.gradle +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2019 Google, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.crane.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' - id 'kotlin-kapt' - id 'dagger.hilt.android.plugin' -} - -// Reads the Google maps key that is used in the AndroidManifest -Properties properties = new Properties() -if (rootProject.file("local.properties").exists()) { - properties.load(rootProject.file("local.properties").newDataInputStream()) -} - -android { - compileSdkVersion 30 - defaultConfig { - applicationId "androidx.compose.samples.crane" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName "1.0" - vectorDrawables.useSupportLibrary = true - testInstrumentationRunner "androidx.compose.samples.crane.CustomTestRunner" - - javaCompileOptions { - annotationProcessorOptions { - arguments["dagger.hilt.disableModulesHaveInstallInCheck"] = "true" - } - } - - manifestPlaceholders = [ googleMapsKey : properties.getProperty("google.maps.key", "") ] - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = "1.8" - } - - buildFeatures { - compose true - - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } - - packagingOptions { - // Multiple dependency bring these files in. Exclude them to enable - // our test APK to build (has no effect on our AARs) - excludes += "/META-INF/AL2.0" - excludes += "/META-INF/LGPL2.1" - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Kotlin.Coroutines.android - implementation Libs.GoogleMaps.maps - implementation Libs.GoogleMaps.mapsKtx - - implementation Libs.Accompanist.coil - implementation Libs.AndroidX.Activity.activityCompose - implementation Libs.AndroidX.appcompat - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.runtimeLivedata - implementation Libs.AndroidX.Compose.foundation - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.animation - implementation Libs.AndroidX.Compose.tooling - implementation Libs.AndroidX.Lifecycle.viewModelCompose - - implementation Libs.AndroidX.Lifecycle.viewModelKtx - implementation Libs.Hilt.android - kapt Libs.Hilt.compiler - - // FIXME: only needed for Compose alpha12 - androidTestImplementation Libs.AndroidX.Activity.activityCompose - - androidTestImplementation Libs.JUnit.junit - androidTestImplementation Libs.AndroidX.Test.runner - androidTestImplementation Libs.AndroidX.Test.espressoCore - androidTestImplementation Libs.AndroidX.Test.rules - androidTestImplementation Libs.AndroidX.Test.Ext.junit - androidTestImplementation Libs.Kotlin.Coroutines.test - androidTestImplementation Libs.AndroidX.Compose.uiTest - androidTestImplementation Libs.Hilt.android - androidTestImplementation Libs.Hilt.testing - kaptAndroidTest Libs.Hilt.compiler -} diff --git a/Crane/app/proguard-rules.pro b/Crane/app/proguard-rules.pro deleted file mode 100644 index 4cb94585a0..0000000000 --- a/Crane/app/proguard-rules.pro +++ /dev/null @@ -1,24 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile - -# Repackage classes into the top-level. --repackageclasses diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/CoroutinesTestRule.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/CoroutinesTestRule.kt deleted file mode 100644 index b3e3d7df9a..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/CoroutinesTestRule.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.rules.TestWatcher -import org.junit.runner.Description - -@OptIn(ExperimentalCoroutinesApi::class) -class CoroutinesTestRule constructor( - val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() -) : TestWatcher() { - - override fun starting(description: Description?) { - super.starting(description) - Dispatchers.setMain(testDispatcher) - } - - override fun finished(description: Description?) { - super.finished(description) - Dispatchers.resetMain() - testDispatcher.cleanupTestCoroutines() - } -} diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/CustomTestRunner.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/CustomTestRunner.kt deleted file mode 100644 index d81394f073..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/CustomTestRunner.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane - -import android.app.Application -import android.content.Context -import androidx.test.runner.AndroidJUnitRunner -import dagger.hilt.android.testing.HiltTestApplication - -class CustomTestRunner : AndroidJUnitRunner() { - override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { - return super.newApplication(cl, HiltTestApplication::class.java.name, context) - } -} diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/calendar/CalendarTest.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/calendar/CalendarTest.kt deleted file mode 100644 index f37a2ccc0b..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/calendar/CalendarTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar - -import androidx.compose.material.Surface -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.FirstDay -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.FirstLastDay -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.LastDay -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.NoSelected -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.Selected -import androidx.compose.samples.crane.data.DatesRepository -import androidx.compose.samples.crane.ui.CraneTheme -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assertContentDescriptionEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Before -import org.junit.Ignore -import org.junit.Rule -import org.junit.Test -import javax.inject.Inject - -@HiltAndroidTest -class CalendarTest { - - @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule() - - @Inject - lateinit var datesRepository: DatesRepository - - @Before - fun setUp() { - hiltRule.inject() - - composeTestRule.setContent { - CraneTheme { - Surface { - CalendarScreen(onBackPressed = {}) - } - } - } - } - - @Ignore("performScrollTo doesn't work with LazyLists: issuetracker.google.com/178483889") - @Test - fun scrollsToTheBottom() { - composeTestRule.onNodeWithContentDescription("January 1").assertIsDisplayed() - composeTestRule.onNodeWithContentDescription("December 31").performScrollTo().performClick() - assert(datesRepository.datesSelected.toString() == "Dec 31") - } - - @Test - fun onDaySelected() { - composeTestRule.onNodeWithContentDescription("January 1").assertIsDisplayed() - composeTestRule.onNodeWithContentDescription("January 2") - .assertIsDisplayed().performClick() - composeTestRule.onNodeWithContentDescription("January 3").assertIsDisplayed() - - val datesNoSelected = composeTestRule.onDateNodes(NoSelected) - datesNoSelected[0].assertContentDescriptionEquals("January 1") - datesNoSelected[1].assertContentDescriptionEquals("January 3") - - composeTestRule.onDateNode(FirstLastDay).assertContentDescriptionEquals("January 2") - } - - @Test - fun twoDaysSelected() { - composeTestRule.onNodeWithContentDescription("January 2") - .assertIsDisplayed().performClick() - - val datesNoSelectedOneClick = composeTestRule.onDateNodes(NoSelected) - datesNoSelectedOneClick[0].assertContentDescriptionEquals("January 1") - datesNoSelectedOneClick[1].assertContentDescriptionEquals("January 3") - - composeTestRule.onNodeWithContentDescription("January 4") - .assertIsDisplayed().performClick() - - composeTestRule.onDateNode(FirstDay).assertContentDescriptionEquals("January 2") - composeTestRule.onDateNode(Selected).assertContentDescriptionEquals("January 3") - composeTestRule.onDateNode(LastDay).assertContentDescriptionEquals("January 4") - - val datesNoSelected = composeTestRule.onDateNodes(NoSelected) - datesNoSelected[0].assertContentDescriptionEquals("January 1") - datesNoSelected[1].assertContentDescriptionEquals("January 5") - } -} - -private fun ComposeTestRule.onDateNode(status: DaySelectedStatus) = onNode( - SemanticsMatcher.expectValue(DayStatusKey, status) -) - -private fun ComposeTestRule.onDateNodes(status: DaySelectedStatus) = onAllNodes( - SemanticsMatcher.expectValue(DayStatusKey, status) -) diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/details/DetailsActivityTest.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/details/DetailsActivityTest.kt deleted file mode 100644 index 7f7c4ad2fb..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/details/DetailsActivityTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.details - -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.data.DestinationsRepository -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.samples.crane.data.MADRID -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onNodeWithText -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.libraries.maps.MapView -import com.google.android.libraries.maps.model.CameraPosition -import com.google.android.libraries.maps.model.LatLng -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.util.concurrent.CountDownLatch -import javax.inject.Inject -import kotlin.math.pow -import kotlin.math.round - -@HiltAndroidTest -class DetailsActivityTest { - - @Inject - lateinit var destinationsRepository: DestinationsRepository - lateinit var cityDetails: ExploreModel - - private val city = MADRID - private val testExploreModel = ExploreModel(city, "description", "imageUrl") - - @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeTestRule = AndroidComposeTestRule( - activityRule = ActivityScenarioRule( - createDetailsActivityIntent( - InstrumentationRegistry.getInstrumentation().targetContext, - testExploreModel - ) - ), - // Needed for now, discussed in https://issuetracker.google.com/issues/174472899 - activityProvider = { rule -> - var activity: DetailsActivity? = null - rule.scenario.onActivity { activity = it } - if (activity == null) { - throw IllegalStateException("Activity was not set in the ActivityScenarioRule!") - } - activity!! - } - ) - - @Before - fun setUp() { - hiltRule.inject() - cityDetails = destinationsRepository.getDestination(MADRID.name)!! - } - - @Test - fun mapView_cameraPositioned() { - composeTestRule.onNodeWithText(cityDetails.city.nameToDisplay).assertIsDisplayed() - composeTestRule.onNodeWithText(cityDetails.description).assertIsDisplayed() - onView(withId(R.id.map)).check(matches(isDisplayed())) - - var cameraPosition: CameraPosition? = null - waitForMap(onCameraPosition = { cameraPosition = it }) - - val expected = LatLng( - testExploreModel.city.latitude.toDouble(), - testExploreModel.city.longitude.toDouble() - ) - assert(expected.latitude == cameraPosition?.target?.latitude?.round(6)) - assert(expected.longitude == cameraPosition?.target?.longitude?.round(6)) - } - - /** - * As the MapView is included using the AndroidView API, it cannot be referenced using Compose - * testing APIs. Therefore, we use the activityRule to get an instance of the DetailsActivity - * an findViewById using MapView's id. - * - * As obtaining the map is an asynchronous call, we use a CountDownLatch to make this - * call synchronous in the test. - */ - private fun waitForMap(onCameraPosition: (CameraPosition) -> Unit) { - val countDownLatch = CountDownLatch(1) - composeTestRule.activityRule.scenario.onActivity { - it.findViewById(R.id.map).getMapAsync { map -> - onCameraPosition(map.cameraPosition) - countDownLatch.countDown() - } - } - countDownLatch.await() - } -} - -private fun Double.round(decimals: Int = 2): Double = - round(this * 10f.pow(decimals)) / 10f.pow(decimals) diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/di/TestDispatchersModule.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/di/TestDispatchersModule.kt deleted file mode 100644 index 3e37215867..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/di/TestDispatchersModule.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("DEPRECATION") - -package androidx.compose.samples.crane.di - -import dagger.Module -import dagger.Provides -import dagger.hilt.components.SingletonComponent -import dagger.hilt.testing.TestInstallIn -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi - -@OptIn(ExperimentalCoroutinesApi::class) -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [DispatchersModule::class] -) -class TestDispatchersModule { - - @Provides - @DefaultDispatcher - fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Unconfined -} diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/home/HomeTest.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/home/HomeTest.kt deleted file mode 100644 index 986ba332f0..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/home/HomeTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@HiltAndroidTest -class HomeTest { - - @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule() - - @Before - fun setUp() { - composeTestRule.setContent { - MainScreen({ }, { }) - } - } - - @Test - fun home_navigatesToAllScreens() { - composeTestRule.onNodeWithText("Explore Flights by Destination").assertIsDisplayed() - composeTestRule.onNodeWithText("SLEEP").performClick() - composeTestRule.onNodeWithText("Explore Properties by Destination").assertIsDisplayed() - composeTestRule.onNodeWithText("EAT").performClick() - composeTestRule.onNodeWithText("Explore Restaurants by Destination").assertIsDisplayed() - composeTestRule.onNodeWithText("FLY").performClick() - composeTestRule.onNodeWithText("Explore Flights by Destination").assertIsDisplayed() - } -} diff --git a/Crane/app/src/debug/AndroidManifest.xml b/Crane/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 17fc280f7c..0000000000 --- a/Crane/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/Crane/app/src/main/AndroidManifest.xml b/Crane/app/src/main/AndroidManifest.xml deleted file mode 100644 index 7d7b70236e..0000000000 --- a/Crane/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Crane/app/src/main/ic_launcher-playstore.png b/Crane/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index acbe14b08a..0000000000 Binary files a/Crane/app/src/main/ic_launcher-playstore.png and /dev/null differ diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/CraneApplication.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/CraneApplication.kt deleted file mode 100644 index 8cb75e6a5e..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/CraneApplication.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane - -import android.app.Application -import dagger.hilt.android.HiltAndroidApp - -@HiltAndroidApp -class CraneApplication : Application() diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/BaseUserInput.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/BaseUserInput.kt deleted file mode 100644 index 961b95ea8a..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/BaseUserInput.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.ui.captionTextStyle -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -@Composable -fun SimpleUserInput( - text: String? = null, - caption: String? = null, - @DrawableRes vectorImageId: Int? = null -) { - CraneUserInput( - caption = if (text == null) caption else null, - text = text ?: "", - vectorImageId = vectorImageId - ) -} - -@Composable -fun CraneUserInput( - text: String, - modifier: Modifier = Modifier, - caption: String? = null, - @DrawableRes vectorImageId: Int? = null, - tint: Color = LocalContentColor.current -) { - CraneBaseUserInput( - modifier = modifier, - caption = caption, - vectorImageId = vectorImageId, - tintIcon = { text.isNotEmpty() }, - tint = tint - ) { - Text(text = text, style = MaterialTheme.typography.body1.copy(color = tint)) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun CraneEditableUserInput( - hint: String, - caption: String? = null, - @DrawableRes vectorImageId: Int? = null, - onInputChanged: (String) -> Unit -) { - var textFieldState by remember { mutableStateOf(TextFieldValue(text = hint)) } - val isHint = { textFieldState.text == hint } - - CraneBaseUserInput( - caption = caption, - tintIcon = { !isHint() }, - showCaption = { !isHint() }, - vectorImageId = vectorImageId - ) { - BasicTextField( - value = textFieldState, - onValueChange = { - textFieldState = it - if (!isHint()) onInputChanged(textFieldState.text) - }, - textStyle = if (isHint()) { - captionTextStyle.copy(color = LocalContentColor.current) - } else { - MaterialTheme.typography.body1.copy(color = LocalContentColor.current) - }, - cursorBrush = SolidColor(LocalContentColor.current) - ) - } -} - -@Composable -private fun CraneBaseUserInput( - modifier: Modifier = Modifier, - caption: String? = null, - @DrawableRes vectorImageId: Int? = null, - showCaption: () -> Boolean = { true }, - tintIcon: () -> Boolean, - tint: Color = LocalContentColor.current, - content: @Composable () -> Unit -) { - Surface(modifier = modifier, color = MaterialTheme.colors.primaryVariant) { - Row(Modifier.padding(all = 12.dp)) { - if (vectorImageId != null) { - Icon( - modifier = Modifier.size(24.dp, 24.dp), - painter = painterResource(id = vectorImageId), - tint = if (tintIcon()) tint else Color(0x80FFFFFF), - contentDescription = null - ) - Spacer(Modifier.width(8.dp)) - } - if (caption != null && showCaption()) { - Text( - modifier = Modifier.align(Alignment.CenterVertically), - text = caption, - style = (captionTextStyle).copy(color = tint) - ) - Spacer(Modifier.width(8.dp)) - } - Row(Modifier.weight(1f).align(Alignment.CenterVertically)) { - content() - } - } - } -} - -@Preview -@Composable -fun PreviewInput() { - CraneScaffold { - CraneBaseUserInput( - tintIcon = { true }, - vectorImageId = R.drawable.ic_plane, - caption = "Caption", - showCaption = { true } - ) { - Text(text = "text", style = MaterialTheme.typography.body1) - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneDrawer.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneDrawer.kt deleted file mode 100644 index 494636dd9e..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneDrawer.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.ui.CraneTheme -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -private val screens = listOf("Find Trips", "My Trips", "Saved Trips", "Price Alerts", "My Account") - -@Composable -fun CraneDrawer(modifier: Modifier = Modifier) { - Column( - modifier - .fillMaxSize() - .padding(start = 24.dp, top = 48.dp) - ) { - Image( - painter = painterResource(R.drawable.ic_crane_drawer), - contentDescription = stringResource(R.string.cd_drawer) - ) - for (screen in screens) { - Spacer(Modifier.height(24.dp)) - Text(text = screen, style = MaterialTheme.typography.h4) - } - } -} - -@Preview -@Composable -fun CraneDrawerPreview() { - CraneTheme { - CraneDrawer() - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneTabs.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneTabs.kt deleted file mode 100644 index c0eb34b8eb..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneTabs.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Tab -import androidx.compose.material.TabRow -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.home.CraneScreen -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.os.ConfigurationCompat - -@Composable -fun CraneTabBar( - modifier: Modifier = Modifier, - onMenuClicked: () -> Unit, - children: @Composable (Modifier) -> Unit -) { - Row(modifier) { - // Separate Row as the children shouldn't have the padding - Row(Modifier.padding(top = 8.dp)) { - Image( - modifier = Modifier - .padding(top = 8.dp) - .clickable(onClick = onMenuClicked), - painter = painterResource(id = R.drawable.ic_menu), - contentDescription = stringResource(id = R.string.cd_menu) - ) - Spacer(Modifier.width(8.dp)) - Image( - painter = painterResource(id = R.drawable.ic_crane_logo), - contentDescription = null - ) - } - children( - Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) - } -} - -@Composable -fun CraneTabs( - modifier: Modifier = Modifier, - titles: List, - tabSelected: CraneScreen, - onTabSelected: (CraneScreen) -> Unit -) { - TabRow( - selectedTabIndex = tabSelected.ordinal, - modifier = modifier, - contentColor = MaterialTheme.colors.onSurface, - indicator = { }, - divider = { } - ) { - titles.forEachIndexed { index, title -> - val selected = index == tabSelected.ordinal - - var textModifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) - if (selected) { - textModifier = - Modifier - .border(BorderStroke(2.dp, Color.White), RoundedCornerShape(16.dp)) - .then(textModifier) - } - - Tab( - selected = selected, - onClick = { onTabSelected(CraneScreen.values()[index]) } - ) { - Text( - modifier = textModifier, - text = title.toUpperCase( - ConfigurationCompat.getLocales(LocalConfiguration.current)[0] - ) - ) - } - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/ExploreSection.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/ExploreSection.kt deleted file mode 100644 index 05e3a616c2..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/ExploreSection.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.samples.crane.home.OnExploreItemClicked -import androidx.compose.samples.crane.ui.BottomSheetShape -import androidx.compose.samples.crane.ui.crane_caption -import androidx.compose.samples.crane.ui.crane_divider_color -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import com.google.accompanist.coil.CoilImage - -@Composable -fun ExploreSection( - modifier: Modifier = Modifier, - title: String, - exploreList: List, - onItemClicked: OnExploreItemClicked -) { - Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) { - Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) { - Text( - text = title, - style = MaterialTheme.typography.caption.copy(color = crane_caption) - ) - Spacer(Modifier.height(8.dp)) - LazyColumn( - modifier = Modifier.weight(1f), - ) { - items(exploreList) { exploreItem -> - Column(Modifier.fillParentMaxWidth()) { - ExploreItem( - modifier = Modifier.fillParentMaxWidth(), - item = exploreItem, - onItemClicked = onItemClicked - ) - Divider(color = crane_divider_color) - } - } - } - } - } -} - -@Composable -private fun ExploreItem( - modifier: Modifier = Modifier, - item: ExploreModel, - onItemClicked: OnExploreItemClicked -) { - Row( - modifier = modifier - .clickable { onItemClicked(item) } - .padding(top = 12.dp, bottom = 12.dp) - ) { - ExploreImageContainer { - CoilImage( - data = item.imageUrl, - fadeIn = true, - contentScale = ContentScale.Crop, - contentDescription = null, - loading = { - Box(Modifier.fillMaxSize()) { - Image( - modifier = Modifier.size(36.dp).align(Alignment.Center), - painter = painterResource(id = R.drawable.ic_crane_logo), - contentDescription = null - ) - } - } - ) - } - Spacer(Modifier.width(24.dp)) - Column { - Text( - text = item.city.nameToDisplay, - style = MaterialTheme.typography.h6 - ) - Spacer(Modifier.height(8.dp)) - Text( - text = item.description, - style = MaterialTheme.typography.caption.copy(color = crane_caption) - ) - } - } -} - -@Composable -private fun ExploreImageContainer(content: @Composable () -> Unit) { - Surface(Modifier.size(width = 60.dp, height = 60.dp), RoundedCornerShape(4.dp)) { - content() - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/Result.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/Result.kt deleted file mode 100644 index 5dad2368e7..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/Result.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -/** - * A generic class that holds a value. - * @param - */ -sealed class Result { - data class Success(val data: T) : Result() - data class Error(val exception: Exception) : Result() -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/Scaffold.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/Scaffold.kt deleted file mode 100644 index 33087fc1f3..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/Scaffold.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.ui.CraneTheme - -@Composable -fun CraneScaffold(content: @Composable () -> Unit) { - CraneTheme { - Surface(color = MaterialTheme.colors.primary) { - content() - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/Calendar.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/Calendar.kt deleted file mode 100644 index bc0b9457d0..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/Calendar.kt +++ /dev/null @@ -1,299 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.calendar.model.CalendarDay -import androidx.compose.samples.crane.calendar.model.CalendarMonth -import androidx.compose.samples.crane.calendar.model.DayOfWeek -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus -import androidx.compose.samples.crane.data.CalendarYear -import androidx.compose.samples.crane.data.DatesLocalDataSource -import androidx.compose.samples.crane.ui.CraneTheme -import androidx.compose.samples.crane.util.Circle -import androidx.compose.samples.crane.util.SemiRect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.SemanticsPropertyKey -import androidx.compose.ui.semantics.SemanticsPropertyReceiver -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -typealias CalendarWeek = List - -@Composable -fun Calendar( - calendarYear: CalendarYear, - onDayClicked: (CalendarDay, CalendarMonth) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn(modifier) { - item { Spacer(Modifier.height(32.dp)) } - for (month in calendarYear) { - itemsCalendarMonth(month = month, onDayClicked = onDayClicked) - item { - Spacer(Modifier.height(32.dp)) - } - } - } -} - -@Composable -private fun MonthHeader(modifier: Modifier = Modifier, month: String, year: String) { - Row(modifier = modifier) { - Text( - modifier = Modifier.weight(1f), - text = month, - style = MaterialTheme.typography.h6 - ) - Text( - modifier = Modifier.align(Alignment.CenterVertically), - text = year, - style = MaterialTheme.typography.caption - ) - } -} - -@Composable -private fun Week( - modifier: Modifier = Modifier, - month: CalendarMonth, - week: CalendarWeek, - onDayClicked: (CalendarDay) -> Unit -) { - val (leftFillColor, rightFillColor) = getLeftRightWeekColors(week, month) - - Row(modifier = modifier) { - val spaceModifiers = Modifier - .weight(1f) - .heightIn(max = CELL_SIZE) - Surface(modifier = spaceModifiers, color = leftFillColor) { - Spacer(Modifier.fillMaxHeight()) - } - for (day in week) { - Day( - day, - onDayClicked, - Modifier.semantics { - contentDescription = "${month.name} ${day.value}" - dayStatusProperty = day.status - } - ) - } - Surface(modifier = spaceModifiers, color = rightFillColor) { - Spacer(Modifier.fillMaxHeight()) - } - } -} - -@Composable -private fun DaysOfWeek(modifier: Modifier = Modifier) { - Row(modifier = modifier) { - for (day in DayOfWeek.values()) { - Day(name = day.name.take(1)) - } - } -} - -@Composable -private fun Day( - day: CalendarDay, - onDayClicked: (CalendarDay) -> Unit, - modifier: Modifier = Modifier -) { - val enabled = day.status != DaySelectedStatus.NonClickable - DayContainer( - modifier = modifier.clickable(enabled) { - if (day.status != DaySelectedStatus.NonClickable) onDayClicked(day) - }, - backgroundColor = day.status.color(MaterialTheme.colors) - ) { - DayStatusContainer(status = day.status) { - Text( - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center), - text = day.value, - style = MaterialTheme.typography.body1.copy(color = Color.White) - ) - } - } -} - -@Composable -private fun Day(name: String) { - DayContainer { - Text( - modifier = Modifier.wrapContentSize(Alignment.Center), - text = name, - style = MaterialTheme.typography.caption.copy(Color.White.copy(alpha = 0.6f)) - ) - } -} - -@Composable -private fun DayContainer( - modifier: Modifier = Modifier, - backgroundColor: Color = Color.Transparent, - content: @Composable () -> Unit -) { - // What if this doesn't fit the screen? - LayoutFlexible(1f) + LayoutAspectRatio(1f) - Surface( - modifier = modifier.size(width = CELL_SIZE, height = CELL_SIZE), - color = backgroundColor - ) { - content() - } -} - -@Composable -private fun DayStatusContainer( - status: DaySelectedStatus, - content: @Composable () -> Unit -) { - if (status.isMarked()) { - Box { - val color = MaterialTheme.colors.secondary - Circle(color = color) - if (status == DaySelectedStatus.FirstDay) { - SemiRect(color = color, lookingLeft = false) - } else if (status == DaySelectedStatus.LastDay) { - SemiRect(color = color, lookingLeft = true) - } - content() - } - } else { - content() - } -} - -private fun LazyListScope.itemsCalendarMonth( - month: CalendarMonth, - onDayClicked: (CalendarDay, CalendarMonth) -> Unit -) { - item { - MonthHeader( - modifier = Modifier.padding(horizontal = 32.dp), - month = month.name, - year = month.year - ) - } - - // Expanding width and centering horizontally - val contentModifier = Modifier - .fillMaxWidth() - .wrapContentWidth(Alignment.CenterHorizontally) - item { - DaysOfWeek(modifier = contentModifier) - } - for (week in month.weeks.value) { - item { - Week( - modifier = contentModifier, - week = week, - month = month, - onDayClicked = { day -> - onDayClicked(day, month) - } - ) - } - item { - Spacer(Modifier.height(8.dp)) - } - } -} - -private fun DaySelectedStatus.color(theme: Colors): Color = when (this) { - DaySelectedStatus.Selected -> theme.secondary - else -> Color.Transparent -} - -@Composable -private fun getLeftRightWeekColors(week: CalendarWeek, month: CalendarMonth): Pair { - val materialColors = MaterialTheme.colors - - val firstDayOfTheWeek = week[0].value - val leftFillColor = if (firstDayOfTheWeek.isNotEmpty()) { - val lastDayPreviousWeek = month.getPreviousDay(firstDayOfTheWeek.toInt()) - if (lastDayPreviousWeek?.status?.isMarked() == true && week[0].status.isMarked()) { - materialColors.secondary - } else { - Color.Transparent - } - } else { - Color.Transparent - } - - val lastDayOfTheWeek = week[6].value - val rightFillColor = if (lastDayOfTheWeek.isNotEmpty()) { - val firstDayNextWeek = month.getNextDay(lastDayOfTheWeek.toInt()) - if (firstDayNextWeek?.status?.isMarked() == true && week[6].status.isMarked()) { - materialColors.secondary - } else { - Color.Transparent - } - } else { - Color.Transparent - } - - return leftFillColor to rightFillColor -} - -private fun DaySelectedStatus.isMarked(): Boolean { - return when (this) { - DaySelectedStatus.Selected -> true - DaySelectedStatus.FirstDay -> true - DaySelectedStatus.LastDay -> true - DaySelectedStatus.FirstLastDay -> true - else -> false - } -} - -private val CELL_SIZE = 48.dp - -val DayStatusKey = SemanticsPropertyKey("DayStatusKey") -var SemanticsPropertyReceiver.dayStatusProperty by DayStatusKey - -@Preview -@Composable -fun DayPreview() { - CraneTheme { - Calendar(DatesLocalDataSource().year2020, onDayClicked = { _, _ -> }) - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarActivity.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarActivity.kt deleted file mode 100644 index a833eed35b..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarActivity.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.base.CraneScaffold -import androidx.compose.samples.crane.calendar.model.CalendarDay -import androidx.compose.samples.crane.calendar.model.CalendarMonth -import androidx.compose.samples.crane.calendar.model.DaySelected -import androidx.compose.samples.crane.data.CalendarYear -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.viewmodel.compose.viewModel -import dagger.hilt.android.AndroidEntryPoint - -fun launchCalendarActivity(context: Context) { - val intent = Intent(context, CalendarActivity::class.java) - context.startActivity(intent) -} - -@AndroidEntryPoint -class CalendarActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - CraneScaffold { - Surface { - CalendarScreen(onBackPressed = { finish() }) - } - } - } - } -} - -@Composable -fun CalendarScreen(onBackPressed: () -> Unit) { - val calendarViewModel: CalendarViewModel = viewModel() - val calendarYear = calendarViewModel.calendarYear - - CalendarContent( - selectedDates = calendarViewModel.datesSelected.toString(), - calendarYear = calendarYear, - onDayClicked = { calendarDay, calendarMonth -> - calendarViewModel.onDaySelected( - DaySelected(calendarDay.value.toInt(), calendarMonth, calendarYear) - ) - }, - onBackPressed = onBackPressed - ) -} - -@Composable -private fun CalendarContent( - selectedDates: String, - calendarYear: CalendarYear, - onDayClicked: (CalendarDay, CalendarMonth) -> Unit, - onBackPressed: () -> Unit -) { - CraneScaffold { - Column { - TopAppBar( - title = { - Text( - text = if (selectedDates.isEmpty()) "Select Dates" - else selectedDates - ) - }, - navigationIcon = { - IconButton(onClick = { onBackPressed() }) { - Image( - painter = painterResource(R.drawable.ic_back), - contentDescription = stringResource(R.string.cd_back) - ) - } - }, - backgroundColor = MaterialTheme.colors.primaryVariant - ) - Surface { - Calendar(calendarYear, onDayClicked) - } - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarViewModel.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarViewModel.kt deleted file mode 100644 index 226847561a..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarViewModel.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar - -import androidx.compose.samples.crane.calendar.model.DaySelected -import androidx.compose.samples.crane.data.DatesRepository -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class CalendarViewModel @Inject constructor( - private val datesRepository: DatesRepository -) : ViewModel() { - - val datesSelected = datesRepository.datesSelected - val calendarYear = datesRepository.calendarYear - - fun onDaySelected(daySelected: DaySelected) { - viewModelScope.launch { - datesRepository.onDaySelected(daySelected) - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarDay.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarDay.kt deleted file mode 100644 index 8e1edb3d8b..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarDay.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar.model - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -enum class DayOfWeek { - Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday -} - -enum class DaySelectedStatus { - NoSelected, Selected, NonClickable, FirstDay, LastDay, FirstLastDay -} - -class CalendarDay(val value: String, status: DaySelectedStatus) { - var status by mutableStateOf(status) -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarMonth.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarMonth.kt deleted file mode 100644 index 19d0d3f8f9..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarMonth.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar.model - -data class CalendarMonth( - val name: String, - val year: String, - val numDays: Int, - val monthNumber: Int, - val startDayOfWeek: DayOfWeek -) { - private val days = mutableListOf().apply { - // Add offset of the start of the month - for (i in 1..startDayOfWeek.ordinal) { - add( - CalendarDay( - "", - DaySelectedStatus.NonClickable - ) - ) - } - // Add days of the month - for (i in 1..numDays) { - add( - CalendarDay( - i.toString(), - DaySelectedStatus.NoSelected - ) - ) - } - }.toList() - - fun getDay(day: Int): CalendarDay { - return days[day + startDayOfWeek.ordinal - 1] - } - - fun getPreviousDay(day: Int): CalendarDay? { - if (day <= 1) return null - return getDay(day - 1) - } - - fun getNextDay(day: Int): CalendarDay? { - if (day >= numDays) return null - return getDay(day + 1) - } - - val weeks = lazy { days.chunked(7).map { completeWeek(it) } } - - private fun completeWeek(list: List): List { - var gapsToFill = 7 - list.size - - return if (gapsToFill != 0) { - val mutableList = list.toMutableList() - while (gapsToFill > 0) { - mutableList.add( - CalendarDay( - "", - DaySelectedStatus.NonClickable - ) - ) - gapsToFill-- - } - mutableList - } else { - list - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DatesSelectedState.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DatesSelectedState.kt deleted file mode 100644 index 8d4cfc64dd..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DatesSelectedState.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar.model - -import androidx.annotation.VisibleForTesting -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.data.CalendarYear - -class DatesSelectedState(private val year: CalendarYear) { - private var from by mutableStateOf(DaySelectedEmpty) - private var to by mutableStateOf(DaySelectedEmpty) - - override fun toString(): String { - if (from == DaySelectedEmpty && to == DaySelectedEmpty) return "" - var output = from.toString() - if (to != DaySelectedEmpty) { - output += " - $to" - } - return output - } - - fun daySelected(newDate: DaySelected) { - if (from == DaySelectedEmpty && to == DaySelectedEmpty) { - setDates(newDate, DaySelectedEmpty) - } else if (from != DaySelectedEmpty && to != DaySelectedEmpty) { - clearDates() - daySelected(newDate = newDate) - } else if (from == DaySelectedEmpty) { - if (newDate < to) setDates(newDate, to) - else if (newDate > to) setDates(to, newDate) - } else if (to == DaySelectedEmpty) { - if (newDate < from) setDates(newDate, from) - else if (newDate > from) setDates(from, newDate) - } - } - - private fun setDates(newFrom: DaySelected, newTo: DaySelected) { - if (newTo == DaySelectedEmpty) { - from = newFrom - from.calendarDay.value.status = DaySelectedStatus.FirstLastDay - } else { - from = newFrom.apply { calendarDay.value.status = DaySelectedStatus.FirstDay } - selectDatesInBetween(newFrom, newTo) - to = newTo.apply { calendarDay.value.status = DaySelectedStatus.LastDay } - } - } - - private fun selectDatesInBetween(from: DaySelected, to: DaySelected) { - if (from.month == to.month) { - for (i in (from.day + 1) until to.day) - from.month.getDay(i).status = DaySelectedStatus.Selected - } else { - // Fill from's month - for (i in (from.day + 1) until from.month.numDays) { - from.month.getDay(i).status = DaySelectedStatus.Selected - } - from.month.getDay(from.month.numDays).status = DaySelectedStatus.LastDay - // Fill in-between months - for (i in (from.month.monthNumber + 1) until to.month.monthNumber) { - val month = year[i - 1] - month.getDay(1).status = DaySelectedStatus.FirstDay - for (j in 2 until month.numDays) { - month.getDay(j).status = DaySelectedStatus.Selected - } - month.getDay(month.numDays).status = DaySelectedStatus.LastDay - } - // Fill to's month - to.month.getDay(1).status = DaySelectedStatus.FirstDay - for (i in 2 until to.day) { - to.month.getDay(i).status = DaySelectedStatus.Selected - } - } - } - - @VisibleForTesting - fun clearDates() { - if (from != DaySelectedEmpty || to != DaySelectedEmpty) { - // Unselect dates from the same month - if (from.month == to.month) { - for (i in from.day..to.day) - from.month.getDay(i).status = DaySelectedStatus.NoSelected - } else { - // Unselect from's month - for (i in from.day..from.month.numDays) { - from.month.getDay(i).status = DaySelectedStatus.NoSelected - } - // Fill in-between months - for (i in (from.month.monthNumber + 1) until to.month.monthNumber) { - val month = year[i - 1] - for (j in 1..month.numDays) { - month.getDay(j).status = DaySelectedStatus.NoSelected - } - } - // Fill to's month - for (i in 1..to.day) { - to.month.getDay(i).status = DaySelectedStatus.NoSelected - } - } - } - from.calendarDay.value.status = DaySelectedStatus.NoSelected - from = DaySelectedEmpty - to.calendarDay.value.status = DaySelectedStatus.NoSelected - to = DaySelectedEmpty - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DaySelected.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DaySelected.kt deleted file mode 100644 index 4dfd0a287c..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DaySelected.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar.model - -import androidx.compose.samples.crane.data.CalendarYear - -data class DaySelected(val day: Int, val month: CalendarMonth, val year: CalendarYear) { - val calendarDay = lazy { - month.getDay(day) - } - - override fun toString(): String { - return "${month.name.substring(0, 3).capitalize()} $day" - } - - operator fun compareTo(other: DaySelected): Int { - if (day == other.day && month == other.month) return 0 - if (month == other.month) return day.compareTo(other.day) - return (year.indexOf(month)).compareTo( - year.indexOf(other.month) - ) - } -} - -/** - * Represents an empty value for [DaySelected] - */ -val DaySelectedEmpty = DaySelected(-1, CalendarMonth("", "", 0, 0, DayOfWeek.Sunday), emptyList()) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/Cities.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/Cities.kt deleted file mode 100644 index 9ae8efad83..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/Cities.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -val MADRID = City( - name = "Madrid", - country = "Spain", - latitude = "40.416775", - longitude = "-3.703790" -) - -val NAPLES = City( - name = "Naples", - country = "Italy", - latitude = "40.853294", - longitude = "14.305573" -) - -val DALLAS = City( - name = "Dallas", - country = "US", - latitude = "32.779167", - longitude = "-96.808891" -) - -val CORDOBA = City( - name = "Cordoba", - country = "Argentina", - latitude = "-31.416668", - longitude = "-64.183334" -) - -val MALDIVAS = City( - name = "Maldivas", - country = "South Asia", - latitude = "1.924992", - longitude = "73.399658" -) - -val ASPEN = City( - name = "Aspen", - country = "Colorado", - latitude = "39.191097", - longitude = "-106.817535" -) - -val BALI = City( - name = "Bali", - country = "Indonesia", - latitude = "-8.3405", - longitude = "115.0920" -) - -val BIGSUR = City( - name = "Big Sur", - country = "California", - latitude = "36.2704", - longitude = "-121.8081" -) - -val KHUMBUVALLEY = City( - name = "Khumbu Valley", - country = "Nepal", - latitude = "27.9320", - longitude = "86.8050" -) - -val ROME = City( - name = "Rome", - country = "Italy", - latitude = "41.902782", - longitude = "12.496366" -) - -val GRANADA = City( - name = "Granada", - country = "Spain", - latitude = "37.18817", - longitude = "-3.60667" -) - -val WASHINGTONDC = City( - name = "Washington DC", - country = "USA", - latitude = "38.9072", - longitude = "-77.0369" -) - -val BARCELONA = City( - name = "Barcelona", - country = "Spain", - latitude = "41.390205", - longitude = "2.154007" -) - -val CRETE = City( - name = "Crete", - country = "Greece", - latitude = "35.2401", - longitude = "24.8093" -) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesLocalDataSource.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesLocalDataSource.kt deleted file mode 100644 index d33dd24959..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesLocalDataSource.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import androidx.compose.samples.crane.calendar.model.CalendarDay -import androidx.compose.samples.crane.calendar.model.CalendarMonth -import androidx.compose.samples.crane.calendar.model.DayOfWeek -import javax.inject.Inject -import javax.inject.Singleton - -typealias CalendarYear = List - -/** - * Annotated with Singleton because [CalendarDay] contains mutable state. - */ -@Singleton -class DatesLocalDataSource @Inject constructor() { - - private val january2020 = CalendarMonth( - name = "January", - year = "2020", - numDays = 31, - monthNumber = 1, - startDayOfWeek = DayOfWeek.Wednesday - ) - private val february2020 = CalendarMonth( - name = "February", - year = "2020", - numDays = 29, - monthNumber = 2, - startDayOfWeek = DayOfWeek.Saturday - ) - private val march2020 = CalendarMonth( - name = "March", - year = "2020", - numDays = 31, - monthNumber = 3, - startDayOfWeek = DayOfWeek.Sunday - ) - private val april2020 = CalendarMonth( - name = "April", - year = "2020", - numDays = 30, - monthNumber = 4, - startDayOfWeek = DayOfWeek.Wednesday - ) - private val may2020 = CalendarMonth( - name = "May", - year = "2020", - numDays = 31, - monthNumber = 5, - startDayOfWeek = DayOfWeek.Friday - ) - private val june2020 = CalendarMonth( - name = "June", - year = "2020", - numDays = 30, - monthNumber = 6, - startDayOfWeek = DayOfWeek.Monday - ) - private val july2020 = CalendarMonth( - name = "July", - year = "2020", - numDays = 31, - monthNumber = 7, - startDayOfWeek = DayOfWeek.Wednesday - ) - private val august2020 = CalendarMonth( - name = "August", - year = "2020", - numDays = 31, - monthNumber = 8, - startDayOfWeek = DayOfWeek.Saturday - ) - private val september2020 = CalendarMonth( - name = "September", - year = "2020", - numDays = 30, - monthNumber = 9, - startDayOfWeek = DayOfWeek.Tuesday - ) - private val october2020 = CalendarMonth( - name = "October", - year = "2020", - numDays = 31, - monthNumber = 10, - startDayOfWeek = DayOfWeek.Thursday - ) - private val november2020 = CalendarMonth( - name = "November", - year = "2020", - numDays = 30, - monthNumber = 11, - startDayOfWeek = DayOfWeek.Sunday - ) - private val december2020 = CalendarMonth( - name = "December", - year = "2020", - numDays = 31, - monthNumber = 12, - startDayOfWeek = DayOfWeek.Tuesday - ) - - val year2020: CalendarYear = listOf( - january2020, - february2020, - march2020, - april2020, - may2020, - june2020, - july2020, - august2020, - september2020, - october2020, - november2020, - december2020 - ) -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesRepository.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesRepository.kt deleted file mode 100644 index 7b0dc0e7c5..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesRepository.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import androidx.compose.samples.crane.calendar.model.DatesSelectedState -import androidx.compose.samples.crane.calendar.model.DaySelected -import androidx.compose.samples.crane.di.DefaultDispatcher -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Annotated with Singleton because [DatesSelectedState] contains mutable state. - */ -@Singleton -class DatesRepository @Inject constructor( - datesLocalDataSource: DatesLocalDataSource, - @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, - -) { - val calendarYear = datesLocalDataSource.year2020 - val datesSelected = DatesSelectedState(datesLocalDataSource.year2020) - - suspend fun onDaySelected(daySelected: DaySelected) = withContext(defaultDispatcher) { - datesSelected.daySelected(daySelected) - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsLocalDataSource.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsLocalDataSource.kt deleted file mode 100644 index bb5d179182..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsLocalDataSource.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import javax.inject.Inject -import javax.inject.Singleton - -private const val DEFAULT_IMAGE_WIDTH = "250" - -/** - * Annotated with Singleton as the class created a lot of objects. - */ -@Singleton -class DestinationsLocalDataSource @Inject constructor() { - - val craneRestaurants = listOf( - ExploreModel( - city = NAPLES, - description = "1286 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1534308983496-4fabb1a015ee?ixlib=rb-1.2.1&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = DALLAS, - description = "2241 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1495749388945-9d6e4e5b67b1?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = CORDOBA, - description = "876 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1562625964-ffe9b2f617fc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=250&q=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = MADRID, - description = "5610 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1515443961218-a51367888e4b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = MALDIVAS, - description = "1286 Restaurants", - imageUrl = "https://images.unsplash.com/flagged/photo-1556202256-af2687079e51?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = ASPEN, - description = "2241 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1542384557-0824d90731ee?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BALI, - description = "876 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1567337710282-00832b415979?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ) - ) - - val craneHotels = listOf( - ExploreModel( - city = MALDIVAS, - description = "1286 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = ASPEN, - description = "2241 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1445019980597-93fa8acb246c?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BALI, - description = "876 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1570213489059-0aac6626cade?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BIGSUR, - description = "5610 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1561409037-c7be81613c1f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = NAPLES, - description = "1286 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1455587734955-081b22074882?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = DALLAS, - description = "2241 Available Properties", - imageUrl = "https://images.unsplash.com/46/sh3y2u5PSaKq8c4LxB3B_submission-photo-4.jpg?ixlib=rb-1.2.1&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = CORDOBA, - description = "876 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1570214476695-19bd467e6f7a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ) - ) - - val craneDestinations = listOf( - ExploreModel( - city = KHUMBUVALLEY, - description = "Nonstop - 5h 16m+", - imageUrl = "https://images.unsplash.com/photo-1544735716-392fe2489ffa?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = MADRID, - description = "Nonstop - 2h 12m+", - imageUrl = "https://images.unsplash.com/photo-1539037116277-4db20889f2d4?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BALI, - description = "Nonstop - 6h 20m+", - imageUrl = "https://images.unsplash.com/photo-1518548419970-58e3b4079ab2?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = ROME, - description = "Nonstop - 2h 38m+", - imageUrl = "https://images.unsplash.com/photo-1515542622106-78bda8ba0e5b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = GRANADA, - description = "Nonstop - 2h 12m+", - imageUrl = "https://images.unsplash.com/photo-1534423839368-1796a4dd1845?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = MALDIVAS, - description = "Nonstop - 9h 24m+", - imageUrl = "https://images.unsplash.com/photo-1544550581-5f7ceaf7f992?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = WASHINGTONDC, - description = "Nonstop - 7h 30m+", - imageUrl = "https://images.unsplash.com/photo-1557160854-e1e89fdd3286?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BARCELONA, - description = "Nonstop - 2h 12m+", - imageUrl = "https://images.unsplash.com/photo-1562883676-8c7feb83f09b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = CRETE, - description = "Nonstop - 1h 50m+", - imageUrl = "https://images.unsplash.com/photo-1486575008575-27670acb58db?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ) - ) -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsRepository.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsRepository.kt deleted file mode 100644 index df419033a4..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsRepository.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import javax.inject.Inject - -class DestinationsRepository @Inject constructor( - private val destinationsLocalDataSource: DestinationsLocalDataSource -) { - val destinations: List = destinationsLocalDataSource.craneDestinations - val hotels: List = destinationsLocalDataSource.craneHotels - val restaurants: List = destinationsLocalDataSource.craneRestaurants - - fun getDestination(cityName: String): ExploreModel? { - return destinationsLocalDataSource.craneDestinations.firstOrNull { - it.city.name == cityName - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/ExploreModel.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/ExploreModel.kt deleted file mode 100644 index 9155e0055f..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/ExploreModel.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import androidx.compose.runtime.Immutable - -@Immutable -data class City( - val name: String, - val country: String, - val latitude: String, - val longitude: String -) { - val nameToDisplay = "$name, $country" -} - -@Immutable -data class ExploreModel( - val city: City, - val description: String, - val imageUrl: String -) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsActivity.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsActivity.kt deleted file mode 100644 index b2b6c81586..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsActivity.kt +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.details - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.base.CraneScaffold -import androidx.compose.samples.crane.base.Result -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.android.libraries.maps.CameraUpdateFactory -import com.google.android.libraries.maps.MapView -import com.google.android.libraries.maps.model.LatLng -import com.google.maps.android.ktx.addMarker -import com.google.maps.android.ktx.awaitMap -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import javax.inject.Inject - -private const val KEY_ARG_DETAILS_CITY_NAME = "KEY_ARG_DETAILS_CITY_NAME" - -fun launchDetailsActivity(context: Context, item: ExploreModel) { - context.startActivity(createDetailsActivityIntent(context, item)) -} - -@VisibleForTesting -fun createDetailsActivityIntent(context: Context, item: ExploreModel): Intent { - val intent = Intent(context, DetailsActivity::class.java) - intent.putExtra(KEY_ARG_DETAILS_CITY_NAME, item.city.name) - return intent -} - -data class DetailsActivityArg( - val cityName: String -) - -@AndroidEntryPoint -class DetailsActivity : ComponentActivity() { - - @Inject - lateinit var viewModelFactory: DetailsViewModelFactory - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val args = getDetailsArgs(intent) - - setContent { - CraneScaffold { - DetailsScreen(args, viewModelFactory, onErrorLoading = { finish() }) - } - } - } - - private fun getDetailsArgs(intent: Intent): DetailsActivityArg { - val cityArg = intent.getStringExtra(KEY_ARG_DETAILS_CITY_NAME) - if (cityArg.isNullOrEmpty()) { - throw IllegalStateException("DETAILS_CITY_NAME arg cannot be null or empty") - } - return DetailsActivityArg(cityArg) - } -} - -@Composable -fun DetailsScreen( - args: DetailsActivityArg, - viewModelFactory: DetailsViewModelFactory, - onErrorLoading: () -> Unit -) { - val viewModel: DetailsViewModel = viewModel( - factory = DetailsViewModel.provideFactory(viewModelFactory, args.cityName) - ) - - val cityDetailsResult = remember(viewModel) { viewModel.cityDetails } - if (cityDetailsResult is Result.Success) { - DetailsContent(cityDetailsResult.data) - } else { - onErrorLoading() - } -} - -@Composable -fun DetailsContent(exploreModel: ExploreModel) { - Column(verticalArrangement = Arrangement.Center) { - Spacer(Modifier.height(32.dp)) - Text( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = exploreModel.city.nameToDisplay, - style = MaterialTheme.typography.h4 - ) - Text( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = exploreModel.description, - style = MaterialTheme.typography.h6 - ) - Spacer(Modifier.height(16.dp)) - CityMapView(exploreModel.city.latitude, exploreModel.city.longitude) - } -} - -@Composable -private fun CityMapView(latitude: String, longitude: String) { - // The MapView lifecycle is handled by this composable. As the MapView also needs to be updated - // with input from Compose UI, those updates are encapsulated into the MapViewContainer - // composable. In this way, when an update to the MapView happens, this composable won't - // recompose and the MapView won't need to be recreated. - val mapView = rememberMapViewWithLifecycle() - MapViewContainer(mapView, latitude, longitude) -} - -@Composable -private fun MapViewContainer( - map: MapView, - latitude: String, - longitude: String -) { - var zoom by rememberSaveable { mutableStateOf(InitialZoom) } - val coroutineScope = rememberCoroutineScope() - - ZoomControls(zoom) { - zoom = it.coerceIn(MinZoom, MaxZoom) - } - AndroidView({ map }) { mapView -> - // Reading zoom so that AndroidView recomposes when it changes. The getMapAsync lambda - // is stored for later, Compose doesn't recognize state reads - val mapZoom = zoom - coroutineScope.launch { - val googleMap = mapView.awaitMap() - googleMap.setZoom(mapZoom) - val position = LatLng(latitude.toDouble(), longitude.toDouble()) - googleMap.addMarker { - position(position) - } - googleMap.moveCamera(CameraUpdateFactory.newLatLng(position)) - } - } -} - -@Composable -private fun ZoomControls( - zoom: Float, - onZoomChanged: (Float) -> Unit -) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - ZoomButton("-", onClick = { onZoomChanged(zoom * 0.8f) }) - ZoomButton("+", onClick = { onZoomChanged(zoom * 1.2f) }) - } -} - -@Composable -private fun ZoomButton(text: String, onClick: () -> Unit) { - Button( - modifier = Modifier.padding(8.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.onPrimary, - contentColor = MaterialTheme.colors.primary - ), - onClick = onClick - ) { - Text(text = text, style = MaterialTheme.typography.h5) - } -} - -private const val InitialZoom = 5f -const val MinZoom = 2f -const val MaxZoom = 20f diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsViewModel.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsViewModel.kt deleted file mode 100644 index 3ff8cdff17..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsViewModel.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.details - -import androidx.compose.samples.crane.base.Result -import androidx.compose.samples.crane.data.DestinationsRepository -import androidx.compose.samples.crane.data.ExploreModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -class DetailsViewModel @AssistedInject constructor( - private val destinationsRepository: DestinationsRepository, - @Assisted private val cityName: String -) : ViewModel() { - - val cityDetails: Result - get() { - val destination = destinationsRepository.getDestination(cityName) - return if (destination != null) { - Result.Success(destination) - } else { - Result.Error(IllegalArgumentException("City doesn't exist")) - } - } - - @Suppress("UNCHECKED_CAST") - companion object { - fun provideFactory( - assistedFactory: DetailsViewModelFactory, - cityName: String - ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return assistedFactory.create(cityName) as T - } - } - } -} - -@AssistedFactory -interface DetailsViewModelFactory { - fun create(cityName: String): DetailsViewModel -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/details/MapViewUtils.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/details/MapViewUtils.kt deleted file mode 100644 index 897fbe54dc..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/details/MapViewUtils.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.details - -import android.os.Bundle -import androidx.annotation.FloatRange -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.samples.crane.R -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import com.google.android.libraries.maps.GoogleMap -import com.google.android.libraries.maps.MapView - -/** - * Remembers a MapView and gives it the lifecycle of the current LifecycleOwner - */ -@Composable -fun rememberMapViewWithLifecycle(): MapView { - val context = LocalContext.current - val mapView = remember { - MapView(context).apply { - id = R.id.map - } - } - - // Makes MapView follow the lifecycle of this composable - val lifecycleObserver = rememberMapLifecycleObserver(mapView) - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - lifecycle.addObserver(lifecycleObserver) - onDispose { - lifecycle.removeObserver(lifecycleObserver) - } - } - - return mapView -} - -@Composable -private fun rememberMapLifecycleObserver(mapView: MapView): LifecycleEventObserver = - remember(mapView) { - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle()) - Lifecycle.Event.ON_START -> mapView.onStart() - Lifecycle.Event.ON_RESUME -> mapView.onResume() - Lifecycle.Event.ON_PAUSE -> mapView.onPause() - Lifecycle.Event.ON_STOP -> mapView.onStop() - Lifecycle.Event.ON_DESTROY -> mapView.onDestroy() - else -> throw IllegalStateException() - } - } - } - -fun GoogleMap.setZoom( - @FloatRange(from = MinZoom.toDouble(), to = MaxZoom.toDouble()) zoom: Float -) { - resetMinMaxZoomPreference() - setMinZoomPreference(zoom) - setMaxZoomPreference(zoom) -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/di/DispatchersModule.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/di/DispatchersModule.kt deleted file mode 100644 index 21ec6554f0..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/di/DispatchersModule.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.di - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import javax.inject.Qualifier - -@Module -@InstallIn(SingletonComponent::class) -class DispatchersModule { - - @Provides - @DefaultDispatcher - fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default -} - -@Retention(AnnotationRetention.BINARY) -@Qualifier -annotation class DefaultDispatcher diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/CraneHome.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/CraneHome.kt deleted file mode 100644 index 8cd0851a69..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/CraneHome.kt +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.material.BackdropScaffold -import androidx.compose.material.BackdropValue -import androidx.compose.material.DrawerValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalDrawer -import androidx.compose.material.rememberBackdropScaffoldState -import androidx.compose.material.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.base.CraneDrawer -import androidx.compose.samples.crane.base.CraneTabBar -import androidx.compose.samples.crane.base.CraneTabs -import androidx.compose.samples.crane.base.ExploreSection -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.lifecycle.viewmodel.compose.viewModel -import kotlinx.coroutines.launch - -typealias OnExploreItemClicked = (ExploreModel) -> Unit - -enum class CraneScreen { - Fly, Sleep, Eat -} - -@Composable -fun CraneHome( - onExploreItemClicked: OnExploreItemClicked, - onDateSelectionClicked: () -> Unit, - modifier: Modifier = Modifier, -) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - ModalDrawer( - drawerState = drawerState, - gesturesEnabled = drawerState.isOpen, - drawerContent = { CraneDrawer() }, - ) { - val scope = rememberCoroutineScope() - CraneHomeContent( - modifier = modifier, - onExploreItemClicked = onExploreItemClicked, - onDateSelectionClicked = onDateSelectionClicked, - openDrawer = { - scope.launch { - drawerState.open() - } - } - ) - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun CraneHomeContent( - onExploreItemClicked: OnExploreItemClicked, - onDateSelectionClicked: () -> Unit, - openDrawer: () -> Unit, - modifier: Modifier = Modifier, -) { - val viewModel: MainViewModel = viewModel() - val suggestedDestinations by viewModel.suggestedDestinations.observeAsState() - - val onPeopleChanged: (Int) -> Unit = { viewModel.updatePeople(it) } - var tabSelected by remember { mutableStateOf(CraneScreen.Fly) } - - BackdropScaffold( - modifier = modifier, - scaffoldState = rememberBackdropScaffoldState(BackdropValue.Revealed), - frontLayerScrimColor = Color.Transparent, - appBar = { - HomeTabBar(openDrawer, tabSelected, onTabSelected = { tabSelected = it }) - }, - backLayerContent = { - SearchContent( - tabSelected, - viewModel, - onPeopleChanged, - onDateSelectionClicked, - onExploreItemClicked - ) - }, - frontLayerContent = { - when (tabSelected) { - CraneScreen.Fly -> { - suggestedDestinations?.let { destinations -> - ExploreSection( - title = "Explore Flights by Destination", - exploreList = destinations, - onItemClicked = onExploreItemClicked - ) - } - } - CraneScreen.Sleep -> { - ExploreSection( - title = "Explore Properties by Destination", - exploreList = viewModel.hotels, - onItemClicked = onExploreItemClicked - ) - } - CraneScreen.Eat -> { - ExploreSection( - title = "Explore Restaurants by Destination", - exploreList = viewModel.restaurants, - onItemClicked = onExploreItemClicked - ) - } - } - } - ) -} - -@Composable -private fun HomeTabBar( - openDrawer: () -> Unit, - tabSelected: CraneScreen, - onTabSelected: (CraneScreen) -> Unit, - modifier: Modifier = Modifier -) { - CraneTabBar( - modifier = modifier, - onMenuClicked = openDrawer - ) { tabBarModifier -> - CraneTabs( - modifier = tabBarModifier, - titles = CraneScreen.values().map { it.name }, - tabSelected = tabSelected, - onTabSelected = { newTab -> onTabSelected(CraneScreen.values()[newTab.ordinal]) } - ) - } -} - -@Composable -private fun SearchContent( - tabSelected: CraneScreen, - viewModel: MainViewModel, - onPeopleChanged: (Int) -> Unit, - onDateSelectionClicked: () -> Unit, - onExploreItemClicked: OnExploreItemClicked -) { - // Reading datesSelected State from here instead of passing the String from the ViewModel - // to cause a recomposition when the dates change. - val datesSelected = viewModel.datesSelected.toString() - - when (tabSelected) { - CraneScreen.Fly -> FlySearchContent( - datesSelected, - searchUpdates = FlySearchContentUpdates( - onPeopleChanged = onPeopleChanged, - onToDestinationChanged = { viewModel.toDestinationChanged(it) }, - onDateSelectionClicked = onDateSelectionClicked, - onExploreItemClicked = onExploreItemClicked - ) - ) - CraneScreen.Sleep -> SleepSearchContent( - datesSelected, - sleepUpdates = SleepSearchContentUpdates( - onPeopleChanged = onPeopleChanged, - onDateSelectionClicked = onDateSelectionClicked, - onExploreItemClicked = onExploreItemClicked - ) - ) - CraneScreen.Eat -> EatSearchContent( - datesSelected, - eatUpdates = EatSearchContentUpdates( - onPeopleChanged = onPeopleChanged, - onDateSelectionClicked = onDateSelectionClicked, - onExploreItemClicked = onExploreItemClicked - ) - ) - } -} - -data class FlySearchContentUpdates( - val onPeopleChanged: (Int) -> Unit, - val onToDestinationChanged: (String) -> Unit, - val onDateSelectionClicked: () -> Unit, - val onExploreItemClicked: OnExploreItemClicked -) - -data class SleepSearchContentUpdates( - val onPeopleChanged: (Int) -> Unit, - val onDateSelectionClicked: () -> Unit, - val onExploreItemClicked: OnExploreItemClicked -) - -data class EatSearchContentUpdates( - val onPeopleChanged: (Int) -> Unit, - val onDateSelectionClicked: () -> Unit, - val onExploreItemClicked: OnExploreItemClicked -) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/HomeFeatures.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/HomeFeatures.kt deleted file mode 100644 index 6058792aa1..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/HomeFeatures.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.base.SimpleUserInput -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun FlySearchContent(datesSelected: String, searchUpdates: FlySearchContentUpdates) { - CraneSearch { - PeopleUserInput( - titleSuffix = ", Economy", - onPeopleChanged = searchUpdates.onPeopleChanged - ) - Spacer(Modifier.height(8.dp)) - FromDestination() - Spacer(Modifier.height(8.dp)) - ToDestinationUserInput(onToDestinationChanged = searchUpdates.onToDestinationChanged) - Spacer(Modifier.height(8.dp)) - DatesUserInput(datesSelected, onDateSelectionClicked = searchUpdates.onDateSelectionClicked) - } -} - -@Composable -fun SleepSearchContent(datesSelected: String, sleepUpdates: SleepSearchContentUpdates) { - CraneSearch { - PeopleUserInput(onPeopleChanged = { sleepUpdates.onPeopleChanged }) - Spacer(Modifier.height(8.dp)) - DatesUserInput(datesSelected, onDateSelectionClicked = sleepUpdates.onDateSelectionClicked) - Spacer(Modifier.height(8.dp)) - SimpleUserInput(caption = "Select Location", vectorImageId = R.drawable.ic_hotel) - } -} - -@Composable -fun EatSearchContent(datesSelected: String, eatUpdates: EatSearchContentUpdates) { - CraneSearch { - PeopleUserInput(onPeopleChanged = { eatUpdates.onPeopleChanged }) - Spacer(Modifier.height(8.dp)) - DatesUserInput(datesSelected, onDateSelectionClicked = eatUpdates.onDateSelectionClicked) - Spacer(Modifier.height(8.dp)) - SimpleUserInput(caption = "Select Time", vectorImageId = R.drawable.ic_time) - Spacer(Modifier.height(8.dp)) - SimpleUserInput(caption = "Select Location", vectorImageId = R.drawable.ic_restaurant) - } -} - -@Composable -private fun CraneSearch(content: @Composable () -> Unit) { - Column(Modifier.padding(start = 24.dp, top = 0.dp, end = 24.dp, bottom = 12.dp)) { - content() - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/LandingScreen.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/LandingScreen.kt deleted file mode 100644 index 2b49e69462..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/LandingScreen.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.samples.crane.R -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import kotlinx.coroutines.delay - -private const val SplashWaitTime: Long = 2000 - -@Composable -fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - // Adds composition consistency. Use the value when LaunchedEffect is first called - val currentOnTimeout by rememberUpdatedState(onTimeout) - - LaunchedEffect(Unit) { - delay(SplashWaitTime) - currentOnTimeout() - } - Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null) - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainActivity.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainActivity.kt deleted file mode 100644 index ad55c93b97..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainActivity.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.annotation.VisibleForTesting -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.Spring.StiffnessLow -import androidx.compose.animation.core.animateDp -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.samples.crane.base.CraneScaffold -import androidx.compose.samples.crane.calendar.launchCalendarActivity -import androidx.compose.samples.crane.details.launchDetailsActivity -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - MainScreen( - onExploreItemClicked = { launchDetailsActivity(context = this, item = it) }, - onDateSelectionClicked = { launchCalendarActivity(this) } - ) - } - } -} - -@VisibleForTesting -@Composable -fun MainScreen(onExploreItemClicked: OnExploreItemClicked, onDateSelectionClicked: () -> Unit) { - CraneScaffold { - val transitionState = remember { MutableTransitionState(SplashState.Shown) } - val transition = updateTransition(transitionState) - val splashAlpha by transition.animateFloat( - transitionSpec = { tween(durationMillis = 100) } - ) { - if (it == SplashState.Shown) 1f else 0f - } - val contentAlpha by transition.animateFloat( - transitionSpec = { tween(durationMillis = 300) } - ) { - if (it == SplashState.Shown) 0f else 1f - } - val contentTopPadding by transition.animateDp( - transitionSpec = { spring(stiffness = StiffnessLow) } - ) { - if (it == SplashState.Shown) 100.dp else 0.dp - } - - Box { - LandingScreen( - modifier = Modifier.alpha(splashAlpha), - onTimeout = { transitionState.targetState = SplashState.Completed } - ) - MainContent( - modifier = Modifier.alpha(contentAlpha), - topPadding = contentTopPadding, - onExploreItemClicked = onExploreItemClicked, - onDateSelectionClicked = onDateSelectionClicked - ) - } - } -} - -@Composable -private fun MainContent( - modifier: Modifier = Modifier, - topPadding: Dp = 0.dp, - onExploreItemClicked: OnExploreItemClicked, - onDateSelectionClicked: () -> Unit -) { - Column(modifier = modifier) { - Spacer(Modifier.padding(top = topPadding)) - CraneHome( - modifier = modifier, - onExploreItemClicked = onExploreItemClicked, - onDateSelectionClicked = onDateSelectionClicked - ) - } -} - -enum class SplashState { Shown, Completed } diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainViewModel.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainViewModel.kt deleted file mode 100644 index c6b2202a28..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainViewModel.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.samples.crane.calendar.model.DatesSelectedState -import androidx.compose.samples.crane.data.DatesRepository -import androidx.compose.samples.crane.data.DestinationsRepository -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.samples.crane.di.DefaultDispatcher -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject -import kotlin.random.Random - -const val MAX_PEOPLE = 4 - -@HiltViewModel -class MainViewModel @Inject constructor( - private val destinationsRepository: DestinationsRepository, - @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, - datesRepository: DatesRepository -) : ViewModel() { - - val hotels: List = destinationsRepository.hotels - val restaurants: List = destinationsRepository.restaurants - val datesSelected: DatesSelectedState = datesRepository.datesSelected - - private val _suggestedDestinations = MutableLiveData>() - val suggestedDestinations: LiveData> - get() = _suggestedDestinations - - init { - _suggestedDestinations.value = destinationsRepository.destinations - } - - fun updatePeople(people: Int) { - viewModelScope.launch { - if (people > MAX_PEOPLE) { - _suggestedDestinations.value = emptyList() - } else { - val newDestinations = withContext(defaultDispatcher) { - destinationsRepository.destinations - .shuffled(Random(people * (1..100).shuffled().first())) - } - _suggestedDestinations.value = newDestinations - } - } - } - - fun toDestinationChanged(newDestination: String) { - viewModelScope.launch { - val newDestinations = withContext(defaultDispatcher) { - destinationsRepository.destinations - .filter { it.city.nameToDisplay.contains(newDestination) } - } - _suggestedDestinations.value = newDestinations - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/SearchUserInput.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/SearchUserInput.kt deleted file mode 100644 index 2fc65bc1ab..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/SearchUserInput.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.animation.animateColor -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.base.CraneEditableUserInput -import androidx.compose.samples.crane.base.CraneUserInput -import androidx.compose.samples.crane.home.PeopleUserInputAnimationState.Invalid -import androidx.compose.samples.crane.home.PeopleUserInputAnimationState.Valid -import androidx.compose.samples.crane.ui.CraneTheme -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview - -enum class PeopleUserInputAnimationState { Valid, Invalid } - -class PeopleUserInputState { - var people by mutableStateOf(1) - private set - - val animationState: MutableTransitionState = - MutableTransitionState(Valid) - - fun addPerson() { - people = (people % (MAX_PEOPLE + 1)) + 1 - updateAnimationState() - } - - private fun updateAnimationState() { - val newState = - if (people > MAX_PEOPLE) Invalid - else Valid - - if (animationState.currentState != newState) animationState.targetState = newState - } -} - -@Composable -fun PeopleUserInput( - titleSuffix: String? = "", - onPeopleChanged: (Int) -> Unit, - peopleState: PeopleUserInputState = remember { PeopleUserInputState() } -) { - Column { - val transitionState = remember { peopleState.animationState } - val tint = tintPeopleUserInput(transitionState) - - val people = peopleState.people - CraneUserInput( - modifier = Modifier.clickable { - peopleState.addPerson() - onPeopleChanged(peopleState.people) - }, - text = if (people == 1) "$people Adult$titleSuffix" else "$people Adults$titleSuffix", - vectorImageId = R.drawable.ic_person, - tint = tint.value - ) - if (transitionState.targetState == Invalid) { - Text( - text = "Error: We don't support more than $MAX_PEOPLE people", - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary) - ) - } - } -} - -@Composable -fun FromDestination() { - CraneUserInput(text = "Seoul, South Korea", vectorImageId = R.drawable.ic_location) -} - -@Composable -fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) { - CraneEditableUserInput( - hint = "Choose Destination", - caption = "To", - vectorImageId = R.drawable.ic_plane, - onInputChanged = onToDestinationChanged - ) -} - -@Composable -fun DatesUserInput(datesSelected: String, onDateSelectionClicked: () -> Unit) { - CraneUserInput( - modifier = Modifier.clickable(onClick = onDateSelectionClicked), - caption = if (datesSelected.isEmpty()) "Select Dates" else null, - text = datesSelected, - vectorImageId = R.drawable.ic_calendar - ) -} - -@Composable -private fun tintPeopleUserInput( - transitionState: MutableTransitionState -): State { - val validColor = MaterialTheme.colors.onSurface - val invalidColor = MaterialTheme.colors.secondary - - val transition = updateTransition(transitionState) - return transition.animateColor( - transitionSpec = { tween(durationMillis = 300) } - ) { - if (it == Valid) validColor else invalidColor - } -} - -@Preview -@Composable -fun PeopleUserInputPreview() { - CraneTheme { - PeopleUserInput(onPeopleChanged = {}) - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/ui/CraneTheme.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/ui/CraneTheme.kt deleted file mode 100644 index 9cbceefd7f..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/ui/CraneTheme.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.ui - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.lightColors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -val crane_caption = Color.DarkGray -val crane_divider_color = Color.LightGray -private val crane_red = Color(0xFFE30425) -private val crane_white = Color.White -private val crane_purple_700 = Color(0xFF720D5D) -private val crane_purple_800 = Color(0xFF5D1049) -private val crane_purple_900 = Color(0xFF4E0D3A) - -val craneColors = lightColors( - primary = crane_purple_800, - secondary = crane_red, - surface = crane_purple_900, - onSurface = crane_white, - primaryVariant = crane_purple_700 -) - -val BottomSheetShape = RoundedCornerShape( - topStart = 20.dp, - topEnd = 20.dp, - bottomStart = 0.dp, - bottomEnd = 0.dp -) - -@Composable -fun CraneTheme(content: @Composable () -> Unit) { - MaterialTheme(colors = craneColors, typography = craneTypography) { - content() - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/ui/Typography.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/ui/Typography.kt deleted file mode 100644 index 4e807b73b4..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/ui/Typography.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.ui - -import androidx.compose.material.Typography -import androidx.compose.samples.crane.R -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -private val light = Font(R.font.raleway_light, FontWeight.W300) -private val regular = Font(R.font.raleway_regular, FontWeight.W400) -private val medium = Font(R.font.raleway_medium, FontWeight.W500) -private val semibold = Font(R.font.raleway_semibold, FontWeight.W600) - -private val craneFontFamily = FontFamily(fonts = listOf(light, regular, medium, semibold)) - -val captionTextStyle = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 16.sp -) - -val craneTypography = Typography( - h1 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W300, - fontSize = 96.sp - ), - h2 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 60.sp - ), - h3 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 48.sp - ), - h4 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 34.sp - ), - h5 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 24.sp - ), - h6 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 20.sp - ), - subtitle1 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W500, - fontSize = 16.sp - ), - subtitle2 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 14.sp - ), - body1 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 16.sp - ), - body2 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 14.sp - ), - button = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 14.sp - ), - caption = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W500, - fontSize = 12.sp - ), - overline = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 12.sp - ) -) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/util/Shapes.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/util/Shapes.kt deleted file mode 100644 index 40ff1d1310..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/util/Shapes.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.util - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color - -@Composable -fun Circle(color: Color) { - Canvas(Modifier.fillMaxSize()) { - drawCircle(color) - } -} - -@Composable -fun SemiRect(color: Color, lookingLeft: Boolean = true) { - Canvas(Modifier.fillMaxSize()) { - val offset = if (lookingLeft) { - Offset(0f, 0f) - } else { - Offset(size.width / 2, 0f) - } - val size = Size(width = size.width / 2, height = size.height) - - drawRect(size = size, topLeft = offset, color = color) - } -} diff --git a/Crane/app/src/main/res/drawable/ic_back.xml b/Crane/app/src/main/res/drawable/ic_back.xml deleted file mode 100644 index 2989fdadae..0000000000 --- a/Crane/app/src/main/res/drawable/ic_back.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/Crane/app/src/main/res/drawable/ic_calendar.xml b/Crane/app/src/main/res/drawable/ic_calendar.xml deleted file mode 100644 index 49dc42f7d9..0000000000 --- a/Crane/app/src/main/res/drawable/ic_calendar.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/Crane/app/src/main/res/drawable/ic_crane_drawer.xml b/Crane/app/src/main/res/drawable/ic_crane_drawer.xml deleted file mode 100644 index 43d2052ef3..0000000000 --- a/Crane/app/src/main/res/drawable/ic_crane_drawer.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - diff --git a/Crane/app/src/main/res/drawable/ic_crane_logo.xml b/Crane/app/src/main/res/drawable/ic_crane_logo.xml deleted file mode 100644 index fae4ebb91f..0000000000 --- a/Crane/app/src/main/res/drawable/ic_crane_logo.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - diff --git a/Crane/app/src/main/res/drawable/ic_hotel.xml b/Crane/app/src/main/res/drawable/ic_hotel.xml deleted file mode 100644 index 1ae921185c..0000000000 --- a/Crane/app/src/main/res/drawable/ic_hotel.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/Crane/app/src/main/res/drawable/ic_launcher_foreground.xml b/Crane/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 499e509030..0000000000 --- a/Crane/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - diff --git a/Crane/app/src/main/res/drawable/ic_location.xml b/Crane/app/src/main/res/drawable/ic_location.xml deleted file mode 100644 index 76676634e1..0000000000 --- a/Crane/app/src/main/res/drawable/ic_location.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - diff --git a/Crane/app/src/main/res/drawable/ic_menu.xml b/Crane/app/src/main/res/drawable/ic_menu.xml deleted file mode 100644 index 3137ba3502..0000000000 --- a/Crane/app/src/main/res/drawable/ic_menu.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - diff --git a/Crane/app/src/main/res/drawable/ic_person.xml b/Crane/app/src/main/res/drawable/ic_person.xml deleted file mode 100644 index b728d4db9e..0000000000 --- a/Crane/app/src/main/res/drawable/ic_person.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/Crane/app/src/main/res/drawable/ic_plane.xml b/Crane/app/src/main/res/drawable/ic_plane.xml deleted file mode 100644 index 056c486e9d..0000000000 --- a/Crane/app/src/main/res/drawable/ic_plane.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/Crane/app/src/main/res/drawable/ic_restaurant.xml b/Crane/app/src/main/res/drawable/ic_restaurant.xml deleted file mode 100644 index 89977ee7dc..0000000000 --- a/Crane/app/src/main/res/drawable/ic_restaurant.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - diff --git a/Crane/app/src/main/res/drawable/ic_time.xml b/Crane/app/src/main/res/drawable/ic_time.xml deleted file mode 100644 index 0db2db0fb6..0000000000 --- a/Crane/app/src/main/res/drawable/ic_time.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - diff --git a/Crane/app/src/main/res/font/raleway_light.ttf b/Crane/app/src/main/res/font/raleway_light.ttf deleted file mode 100755 index b5ec486060..0000000000 Binary files a/Crane/app/src/main/res/font/raleway_light.ttf and /dev/null differ diff --git a/Crane/app/src/main/res/font/raleway_medium.ttf b/Crane/app/src/main/res/font/raleway_medium.ttf deleted file mode 100755 index 070ac7691f..0000000000 Binary files a/Crane/app/src/main/res/font/raleway_medium.ttf and /dev/null differ diff --git a/Crane/app/src/main/res/font/raleway_regular.ttf b/Crane/app/src/main/res/font/raleway_regular.ttf deleted file mode 100755 index 746c242383..0000000000 Binary files a/Crane/app/src/main/res/font/raleway_regular.ttf and /dev/null differ diff --git a/Crane/app/src/main/res/font/raleway_semibold.ttf b/Crane/app/src/main/res/font/raleway_semibold.ttf deleted file mode 100755 index 34db420617..0000000000 Binary files a/Crane/app/src/main/res/font/raleway_semibold.ttf and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Crane/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 7353dbd1fd..0000000000 --- a/Crane/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/Crane/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index c3bc12a985..0000000000 Binary files a/Crane/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index ac3662d8db..0000000000 Binary files a/Crane/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index d16629b965..0000000000 Binary files a/Crane/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 5cb5cfe5b3..0000000000 Binary files a/Crane/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index ba761daff2..0000000000 Binary files a/Crane/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/values/colors.xml b/Crane/app/src/main/res/values/colors.xml deleted file mode 100644 index cc7848b999..0000000000 --- a/Crane/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - #5D1049 - #3D0A2C - #E30425 - \ No newline at end of file diff --git a/Crane/app/src/main/res/values/ic_launcher_background.xml b/Crane/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index ec64c6ca8d..0000000000 --- a/Crane/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - #5D1049 - \ No newline at end of file diff --git a/Crane/app/src/main/res/values/ids.xml b/Crane/app/src/main/res/values/ids.xml deleted file mode 100644 index c873906ccf..0000000000 --- a/Crane/app/src/main/res/values/ids.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - \ No newline at end of file diff --git a/Crane/app/src/main/res/values/strings.xml b/Crane/app/src/main/res/values/strings.xml deleted file mode 100644 index 71db4f4073..0000000000 --- a/Crane/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - Crane - - Menu - Back - Loading - Open drawer - \ No newline at end of file diff --git a/Crane/app/src/main/res/values/styles.xml b/Crane/app/src/main/res/values/styles.xml deleted file mode 100644 index 79df355fd1..0000000000 --- a/Crane/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - \ No newline at end of file diff --git a/Crane/app/src/release/res/values/google_maps_api.xml b/Crane/app/src/release/res/values/google_maps_api.xml deleted file mode 100644 index b4a93a82c6..0000000000 --- a/Crane/app/src/release/res/values/google_maps_api.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - YOUR_KEY_HERE - \ No newline at end of file diff --git a/Crane/build.gradle b/Crane/build.gradle deleted file mode 100644 index 8b95a00aea..0000000000 --- a/Crane/build.gradle +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.crane.buildsrc.Libs -import com.example.crane.buildsrc.Urls -import com.example.crane.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - classpath Libs.Hilt.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.10.0' -} - -subprojects { - repositories { - google() - jcenter() - mavenCentral() - - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { url Urls.composeSnapshotRepo } - maven { url Urls.mavenCentralSnapshotRepo } - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - ktlint(Versions.ktLint) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - jvmTarget = "1.8" - - // Use experimental APIs - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - } - } - // androidx.test and hilt are forcing JUnit, 4.12. This forces them to use 4.13 - configurations.configureEach { - resolutionStrategy { - force Libs.JUnit.junit - } - } -} diff --git a/Crane/buildSrc/build.gradle.kts b/Crane/buildSrc/build.gradle.kts deleted file mode 100644 index 2567fced51..0000000000 --- a/Crane/buildSrc/build.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import org.gradle.kotlin.dsl.`kotlin-dsl` - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Crane/buildSrc/src/main/java/com/example/crane/buildsrc/Dependencies.kt b/Crane/buildSrc/src/main/java/com/example/crane/buildsrc/Dependencies.kt deleted file mode 100644 index 8410babbfa..0000000000 --- a/Crane/buildSrc/src/main/java/com/example/crane/buildsrc/Dependencies.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.crane.buildsrc - -object Versions { - const val ktLint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha11" - const val ktLint = "com.pinterest:ktlint:${Versions.ktLint}" - - object GoogleMaps { - const val maps = "com.google.android.libraries.maps:maps:3.1.0-beta" - const val mapsKtx = "com.google.maps.android:maps-v3-ktx:2.2.0" - } - - object Accompanist { - private const val version = "0.7.0" - const val coil = "com.google.accompanist:accompanist-coil:$version" - } - - object Kotlin { - private const val version = "1.4.31" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - - object Coroutines { - private const val version = "1.4.2" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - } - - object AndroidX { - object Activity { - const val activityCompose = "androidx.activity:activity-compose:1.3.0-alpha05" - } - - const val appcompat = "androidx.appcompat:appcompat:1.3.0-beta01" - - object Compose { - const val snapshot = "" - private const val version = "1.0.0-beta03" - - const val runtime = "androidx.compose.runtime:runtime:$version" - const val runtimeLivedata = "androidx.compose.runtime:runtime-livedata:$version" - const val material = "androidx.compose.material:material:$version" - const val foundation = "androidx.compose.foundation:foundation:$version" - const val layout = "androidx.compose.foundation:foundation-layout:$version" - const val tooling = "androidx.compose.ui:ui-tooling:$version" - const val animation = "androidx.compose.animation:animation:$version" - const val uiTest = "androidx.compose.ui:ui-test-junit4:$version" - } - - object Lifecycle { - private const val version = "2.3.0" - const val viewModelCompose = "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha03" - const val viewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - } - - object Test { - private const val version = "1.2.0" - const val runner = "androidx.test:runner:$version" - const val rules = "androidx.test:rules:$version" - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - } - - object Hilt { - private const val version = "2.31.2-alpha" - - const val gradlePlugin = "com.google.dagger:hilt-android-gradle-plugin:$version" - const val android = "com.google.dagger:hilt-android:$version" - const val compiler = "com.google.dagger:hilt-compiler:$version" - const val testing = "com.google.dagger:hilt-android-testing:$version" - } - - object JUnit { - private const val version = "4.13" - const val junit = "junit:junit:$version" - } -} - -object Urls { - const val mavenCentralSnapshotRepo = "https://oss.sonatype.org/content/repositories/snapshots/" - const val composeSnapshotRepo = "https://androidx.dev/snapshots/builds/" + - "${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" -} diff --git a/Crane/debug.keystore b/Crane/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Crane/debug.keystore and /dev/null differ diff --git a/Crane/gradle.properties b/Crane/gradle.properties deleted file mode 100644 index 5e5ee02b5e..0000000000 --- a/Crane/gradle.properties +++ /dev/null @@ -1,45 +0,0 @@ -# -# Copyright 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m - -# Turn on parallel compilation, caching and on-demand configuration -org.gradle.configureondemand=true -org.gradle.caching=true -org.gradle.parallel=true - -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -# Needed for com.google.android.libraries.maps:maps -android.enableJetifier=true - -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Crane/gradle/wrapper/gradle-wrapper.jar b/Crane/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e708b1c023..0000000000 Binary files a/Crane/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/Crane/gradle/wrapper/gradle-wrapper.properties b/Crane/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2a563242c1..0000000000 --- a/Crane/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/Crane/gradlew b/Crane/gradlew deleted file mode 100755 index 4f906e0c81..0000000000 --- a/Crane/gradlew +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env sh - -# -# Copyright 2015 the original author or authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -exec "$JAVACMD" "$@" diff --git a/Crane/gradlew.bat b/Crane/gradlew.bat deleted file mode 100644 index ac1b06f938..0000000000 --- a/Crane/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/Crane/screenshots/crane.gif b/Crane/screenshots/crane.gif deleted file mode 100644 index c0690e52ce..0000000000 Binary files a/Crane/screenshots/crane.gif and /dev/null differ diff --git a/Crane/settings.gradle b/Crane/settings.gradle deleted file mode 100644 index e7b4def49c..0000000000 --- a/Crane/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -include ':app' diff --git a/JetLagged/.editorconfig b/JetLagged/.editorconfig new file mode 100644 index 0000000000..185bc386f4 --- /dev/null +++ b/JetLagged/.editorconfig @@ -0,0 +1,27 @@ +# When authoring changes in .editorconfig, run ./gradlew spotlessApply --no-daemon +# Reference: https://github.com/diffplug/spotless/issues/1924 +[*.{kt,kts}] +ktlint_code_style = android_studio +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +max_line_length = 140 # ktlint official +ktlint_function_naming_ignore_when_annotated_with = Composable, Test +ktlint_standard_filename = disabled +ktlint_standard_package-name = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_backing-property-naming = disabled +ktlint_standard_argument-list-wrapping=disabled +ktlint_standard_parameter-list-wrapping = disabled +ktlint_standard_double-colon-spacing=disabled +ktlint_standard_enum-entry-name-case=disabled +ktlint_standard_multiline-if-else=disabled +ktlint_standard_no-empty-first-line-in-method-block = disabled +ktlint_standard_package-name = disabled +ktlint_standard_trailing-comma = disabled +ktlint_standard_spacing-around-angle-brackets = disabled +ktlint_standard_spacing-between-declarations-with-annotations = disabled +ktlint_standard_spacing-between-declarations-with-comments = disabled +ktlint_standard_unary-op-spacing = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_value-parameter-comment = disabled +ktlint_argument_list_wrapping_ignore_when_parameter_count_greater_or_equal_than= unset diff --git a/JetLagged/.gitignore b/JetLagged/.gitignore new file mode 100644 index 0000000000..834ecd9dff --- /dev/null +++ b/JetLagged/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +.kotlin/ diff --git a/JetLagged/.google/packaging.yaml b/JetLagged/.google/packaging.yaml new file mode 100644 index 0000000000..5860ae69be --- /dev/null +++ b/JetLagged/.google/packaging.yaml @@ -0,0 +1,32 @@ +# Copyright (C) 2022 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# GOOGLE SAMPLE PACKAGING DATA +# +# This file is used by Google as part of our samples packaging process. +# End users may safely ignore this file. It has no relevance to other systems. +--- +status: PUBLISHED +technologies: [Android, JetpackCompose] +categories: + - JetpackComposeGraphics + - JetpackComposeLayouts + - JetpackComposeAnimation +languages: [Kotlin] +solutions: [Mobile] +github: android/compose-samples +level: ADVANCED +apiRefs: + - android:androidx.compose.Composable +license: apache2 diff --git a/Jetsurvey/ASSETS_LICENSE b/JetLagged/ASSETS_LICENSE similarity index 100% rename from Jetsurvey/ASSETS_LICENSE rename to JetLagged/ASSETS_LICENSE diff --git a/JetLagged/README.md b/JetLagged/README.md new file mode 100644 index 0000000000..9a5b08b049 --- /dev/null +++ b/JetLagged/README.md @@ -0,0 +1,39 @@ +# JetLagged sample + +JetLagged is a sample sleep tracking app built with [Jetpack Compose][compose]. + +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). +You can clone this repository or import the +project from Android Studio following the steps +[here](https://developer.android.com/jetpack/compose/setup#sample). + +Features: +* Medium complexity +* Custom Layouts +* Graphics: Custom Paths, Gradients, AGSL shaders +* Animations + +## Screenshots + +JetLagged + +## License + +``` +Copyright 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +[compose]: https://developer.android.com/jetpack/compose diff --git a/Crane/buildSrc/.gitignore b/JetLagged/app/.gitignore similarity index 100% rename from Crane/buildSrc/.gitignore rename to JetLagged/app/.gitignore diff --git a/JetLagged/app/build.gradle.kts b/JetLagged/app/build.gradle.kts new file mode 100644 index 0000000000..a3da6afdef --- /dev/null +++ b/JetLagged/app/build.gradle.kts @@ -0,0 +1,144 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.compose) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "com.example.jetlagged" + + defaultConfig { + applicationId = "com.example.jetlagged" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + // Important: change the keystore for a production deployment + val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore") + val localKeystore = rootProject.file("debug_2.keystore") + val hasKeyInfo = userKeystore.exists() + create("release") { + storeFile = if (hasKeyInfo) userKeystore else localKeystore + storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password") + keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias") + keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password") + } + } + + buildTypes { + getByName("debug") { + } + + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("release") + matchingFallbacks.add("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-benchmark-rules.pro", + ) + isDebuggable = false + } + } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + // Disable unused AGP features + buildConfig = false + aidl = false + renderScript = false + resValues = false + shaders = false + } + + packaging.resources { + // Multiple dependency bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + coreLibraryDesugaring(libs.core.jdk.desugaring) + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.constraintlayout.compose) + + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.ui.util) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.materialWindow) + implementation(libs.androidx.compose.ui.googlefonts) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.coil.kt.compose) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.compose.ui.test) + + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/JetLagged/app/proguard-benchmark-rules.pro b/JetLagged/app/proguard-benchmark-rules.pro new file mode 100644 index 0000000000..5849b43aae --- /dev/null +++ b/JetLagged/app/proguard-benchmark-rules.pro @@ -0,0 +1,28 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses + +# When generating the baseline profile we want the proper names of +# the methods and classes +-dontobfuscate \ No newline at end of file diff --git a/JetLagged/app/proguard-rules.pro b/JetLagged/app/proguard-rules.pro new file mode 100644 index 0000000000..6e1d10e809 --- /dev/null +++ b/JetLagged/app/proguard-rules.pro @@ -0,0 +1,35 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE diff --git a/JetLagged/app/src/androidTest/java/com/example/jetlagged/AppTest.kt b/JetLagged/app/src/androidTest/java/com/example/jetlagged/AppTest.kt new file mode 100644 index 0000000000..55940bedcb --- /dev/null +++ b/JetLagged/app/src/androidTest/java/com/example/jetlagged/AppTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.example.jetlagged.ui.theme.JetLaggedTheme +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class AppTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setUp() { + composeTestRule.setContent { + JetLaggedTheme { + JetLaggedScreen() + } + } + } + + @Test + fun app_launches() { + // Check app launches at the correct destination + composeTestRule.onNodeWithText("JetLagged").assertIsDisplayed() + } +} diff --git a/JetLagged/app/src/main/AndroidManifest.xml b/JetLagged/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..555ffefe4a --- /dev/null +++ b/JetLagged/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/JetLagged/app/src/main/ic_launcher-playstore.png b/JetLagged/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..beba0aec1d Binary files /dev/null and b/JetLagged/app/src/main/ic_launcher-playstore.png differ diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/HomeScreenCards.kt b/JetLagged/app/src/main/java/com/example/jetlagged/HomeScreenCards.kt new file mode 100644 index 0000000000..f89a6d61af --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/HomeScreenCards.kt @@ -0,0 +1,273 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterStart +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.jetlagged.backgrounds.BubbleBackground +import com.example.jetlagged.backgrounds.FadingCircleBackground +import com.example.jetlagged.data.WellnessData +import com.example.jetlagged.ui.theme.HeadingStyle +import com.example.jetlagged.ui.theme.JetLaggedTheme +import com.example.jetlagged.ui.theme.SmallHeadingStyle + +@Composable +fun BasicInformationalCard(modifier: Modifier = Modifier, borderColor: Color, content: @Composable () -> Unit) { + val shape = RoundedCornerShape(24.dp) + Card( + shape = shape, + colors = CardDefaults.cardColors( + containerColor = JetLaggedTheme.extraColors.cardBackground, + ), + modifier = modifier + .padding(8.dp), + border = BorderStroke(2.dp, borderColor), + ) { + Box { + content() + } + } +} + +@Composable +fun TwoLineInfoCard( + borderColor: Color, + firstLineText: String, + secondLineText: String, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, +) { + BasicInformationalCard( + borderColor = borderColor, + modifier = modifier.size(200.dp), + ) { + BubbleBackground( + modifier = Modifier.fillMaxSize(), + numberBubbles = 3, bubbleColor = borderColor.copy(0.25f), + ) + BoxWithConstraints( + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + ) { + if (maxWidth > 400.dp) { + Row( + modifier = Modifier + .wrapContentSize() + .align(CenterStart), + ) { + Icon( + painter = painterResource(id = icon), contentDescription = null, + modifier = Modifier + .size(50.dp) + .align(CenterVertically), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier + .align(CenterVertically) + .wrapContentSize(), + ) { + Text( + firstLineText, + style = SmallHeadingStyle, + ) + Text( + secondLineText, + style = HeadingStyle, + ) + } + } + } else { + Column( + modifier = Modifier + .wrapContentSize() + .align(Center), + ) { + Icon( + painter = painterResource(id = icon), contentDescription = null, + modifier = Modifier + .size(50.dp) + .align(CenterHorizontally), + ) + Spacer(modifier = Modifier.height(16.dp)) + Column(modifier = Modifier.align(CenterHorizontally)) { + Text( + firstLineText, + style = SmallHeadingStyle, + modifier = Modifier.align(CenterHorizontally), + ) + Text( + secondLineText, + style = HeadingStyle, + modifier = Modifier.align(CenterHorizontally), + ) + } + } + } + } + } +} + +@Preview +@Preview(widthDp = 500, name = "larger screen") +@Composable +fun AverageTimeInBedCard(modifier: Modifier = Modifier) { + TwoLineInfoCard( + borderColor = JetLaggedTheme.extraColors.bed, + firstLineText = stringResource(R.string.ave_time_in_bed_heading), + secondLineText = "8h42min", + icon = R.drawable.ic_watch, + modifier = modifier + .wrapContentWidth() + .heightIn(min = 156.dp), + ) +} + +@Preview +@Preview(widthDp = 500, name = "larger screen") +@Composable +fun AverageTimeAsleepCard(modifier: Modifier = Modifier) { + TwoLineInfoCard( + borderColor = JetLaggedTheme.extraColors.sleep, + firstLineText = stringResource(R.string.ave_time_sleep_heading), + secondLineText = "7h42min", + icon = R.drawable.ic_single_bed, + modifier = modifier + .wrapContentWidth() + .heightIn(min = 156.dp), + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Preview +@Composable +fun WellnessCard(modifier: Modifier = Modifier, wellnessData: WellnessData = WellnessData(0, 0, 0)) { + BasicInformationalCard( + borderColor = JetLaggedTheme.extraColors.wellness, + modifier = modifier + .widthIn(max = 400.dp) + .heightIn(min = 200.dp), + ) { + FadingCircleBackground(36.dp, JetLaggedTheme.extraColors.wellness.copy(0.25f)) + Column( + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .fillMaxWidth(), + ) { + HomeScreenCardHeading(text = stringResource(R.string.wellness_heading)) + FlowRow( + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxHeight(), + ) { + WellnessBubble( + titleText = stringResource(R.string.snoring_heading), + countText = wellnessData.snoring.toString(), + metric = "min", + ) + WellnessBubble( + titleText = stringResource(R.string.coughing_heading), + countText = wellnessData.coughing.toString(), + metric = "times", + ) + WellnessBubble( + titleText = stringResource(R.string.respiration_heading), + countText = wellnessData.respiration.toString(), + metric = "rpm", + ) + } + } + } +} + +@Composable +fun WellnessBubble( + titleText: String, + countText: String, + metric: String, + modifier: Modifier = Modifier, + bubbleColor: Color = JetLaggedTheme.extraColors.wellness, +) { + Column( + modifier = modifier + .padding(4.dp) + .sizeIn(maxHeight = 100.dp) + .aspectRatio(1f) + .drawBehind { + drawCircle(bubbleColor) + }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = CenterHorizontally, + ) { + Text(titleText, fontSize = 12.sp) + Text(countText, fontSize = 36.sp) + Text(metric, fontSize = 12.sp) + } +} + +@Composable +fun HomeScreenCardHeading(text: String) { + Text( + text, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + textAlign = TextAlign.Center, + style = HeadingStyle, + ) +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/JetLaggedDrawer.kt b/JetLagged/app/src/main/java/com/example/jetlagged/JetLaggedDrawer.kt new file mode 100644 index 0000000000..29bee4cb52 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/JetLaggedDrawer.kt @@ -0,0 +1,268 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged + +import android.os.SystemClock +import androidx.activity.compose.PredictiveBackHandler +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.launch + +@Composable +fun HomeScreenDrawer(windowSizeClass: WindowSizeClass) { + + Surface( + modifier = Modifier.fillMaxSize(), + ) { + var drawerState by remember { + mutableStateOf(DrawerState.Closed) + } + var screenState by remember { + mutableStateOf(Screen.Home) + } + + val translationX = remember { + Animatable(0f) + } + + val drawerWidth = with(LocalDensity.current) { + DrawerWidth.toPx() + } + translationX.updateBounds(0f, drawerWidth) + + val coroutineScope = rememberCoroutineScope() + + suspend fun closeDrawer(velocity: Float = 0f) { + translationX.animateTo(targetValue = 0f, initialVelocity = velocity) + drawerState = DrawerState.Closed + } + suspend fun openDrawer(velocity: Float = 0f) { + translationX.animateTo(targetValue = drawerWidth, initialVelocity = velocity) + drawerState = DrawerState.Open + } + fun toggleDrawerState() { + coroutineScope.launch { + if (drawerState == DrawerState.Open) { + closeDrawer() + } else { + openDrawer() + } + } + } + val velocityTracker = remember { + VelocityTracker() + } + PredictiveBackHandler(drawerState == DrawerState.Open) { progress -> + try { + progress.collect { backEvent -> + val targetSize = (drawerWidth - (drawerWidth * backEvent.progress)) + translationX.snapTo(targetSize) + velocityTracker.addPosition( + SystemClock.uptimeMillis(), + Offset(backEvent.touchX, backEvent.touchY), + ) + } + closeDrawer(velocityTracker.calculateVelocity().x) + } catch (_: CancellationException) { + openDrawer(velocityTracker.calculateVelocity().x) + } + velocityTracker.resetTracking() + } + + HomeScreenDrawerContents( + selectedScreen = screenState, + onScreenSelected = { screen -> + screenState = screen + }, + ) + + val draggableState = rememberDraggableState(onDelta = { dragAmount -> + coroutineScope.launch { + translationX.snapTo(translationX.value + dragAmount) + } + }) + val decay = rememberSplineBasedDecay() + ScreenContents( + windowWidthSizeClass = windowSizeClass.widthSizeClass, + selectedScreen = screenState, + onDrawerClicked = ::toggleDrawerState, + modifier = Modifier + .graphicsLayer { + this.translationX = translationX.value + val scale = lerp(1f, 0.8f, translationX.value / drawerWidth) + this.scaleX = scale + this.scaleY = scale + val roundedCorners = lerp(0f, 32.dp.toPx(), translationX.value / drawerWidth) + this.shape = RoundedCornerShape(roundedCorners) + this.clip = true + this.shadowElevation = 32f + } + // This example is showing how to use draggable with custom logic on stop to snap to the edges + // You can also use `anchoredDraggable()` to set up anchors and not need to worry about more calculations. + .draggable( + draggableState, Orientation.Horizontal, + onDragStopped = { velocity -> + val targetOffsetX = decay.calculateTargetValue( + translationX.value, + velocity, + ) + coroutineScope.launch { + val actualTargetX = if (targetOffsetX > drawerWidth * 0.5) { + drawerWidth + } else { + 0f + } + // checking if the difference between the target and actual is + or - + val targetDifference = (actualTargetX - targetOffsetX) + val canReachTargetWithDecay = + ( + targetOffsetX > actualTargetX && + velocity > 0f && + targetDifference > 0f + ) || + ( + targetOffsetX < actualTargetX && + velocity < 0 && + targetDifference < 0f + ) + if (canReachTargetWithDecay) { + translationX.animateDecay( + initialVelocity = velocity, + animationSpec = decay, + ) + } else { + translationX.animateTo(actualTargetX, initialVelocity = velocity) + } + drawerState = if (actualTargetX == drawerWidth) { + DrawerState.Open + } else { + DrawerState.Closed + } + } + }, + ), + ) + } +} + +@Composable +private fun ScreenContents( + windowWidthSizeClass: WindowWidthSizeClass, + selectedScreen: Screen, + onDrawerClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier) { + when (selectedScreen) { + Screen.Home -> + JetLaggedScreen( + windowSizeClass = windowWidthSizeClass, + modifier = Modifier, + onDrawerClicked = onDrawerClicked, + ) + + Screen.SleepDetails -> + Surface( + modifier = Modifier.fillMaxSize(), + ) { + } + + Screen.Leaderboard -> + Surface( + modifier = Modifier.fillMaxSize(), + ) { + } + + Screen.Settings -> + Surface( + modifier = Modifier.fillMaxSize(), + ) { + } + } + } +} + +private enum class DrawerState { + Open, + Closed, +} + +@Composable +private fun HomeScreenDrawerContents(selectedScreen: Screen, onScreenSelected: (Screen) -> Unit, modifier: Modifier = Modifier) { + Column( + modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + ) { + Screen.entries.forEach { + NavigationDrawerItem( + label = { + Text(it.text) + }, + icon = { + Icon(painter = painterResource(id = it.icon), contentDescription = it.text) + }, + selected = selectedScreen == it, + onClick = { + onScreenSelected(it) + }, + ) + } + } +} + +private val DrawerWidth = 300.dp + +private enum class Screen(val text: String, @DrawableRes val icon: Int) { + Home("Home", R.drawable.ic_home), + SleepDetails("Sleep", R.drawable.ic_bedtime), + Leaderboard("Leaderboard", R.drawable.ic_leaderboard), + Settings("Settings", R.drawable.ic_settings), +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/JetLaggedScreen.kt b/JetLagged/app/src/main/java/com/example/jetlagged/JetLaggedScreen.kt new file mode 100644 index 0000000000..c2c7860815 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/JetLaggedScreen.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowColumn +import androidx.compose.foundation.layout.FlowRow +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.heightIn +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.jetlagged.backgrounds.movingStripesBackground +import com.example.jetlagged.data.JetLaggedHomeScreenViewModel +import com.example.jetlagged.heartrate.HeartRateCard +import com.example.jetlagged.sleep.JetLaggedHeader +import com.example.jetlagged.sleep.JetLaggedSleepGraphCard +import com.example.jetlagged.ui.theme.JetLaggedTheme +import com.example.jetlagged.ui.util.MultiDevicePreview + +@OptIn(ExperimentalLayoutApi::class) +@MultiDevicePreview +@Composable +fun JetLaggedScreen( + modifier: Modifier = Modifier, + windowSizeClass: WindowWidthSizeClass = WindowWidthSizeClass.Compact, + viewModel: JetLaggedHomeScreenViewModel = viewModel(), + onDrawerClicked: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.background), + ) { + Column( + modifier = Modifier.movingStripesBackground( + stripeColor = JetLaggedTheme.extraColors.header, + backgroundColor = MaterialTheme.colorScheme.background, + ), + ) { + JetLaggedHeader( + modifier = Modifier.fillMaxWidth(), + onDrawerClicked = onDrawerClicked, + ) + } + + val uiState = + viewModel.uiState.collectAsStateWithLifecycle() + val insets = WindowInsets.safeDrawing.only( + WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal, + ) + FlowRow( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(insets), + horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Center, + maxItemsInEachRow = 3, + ) { + JetLaggedSleepGraphCard(uiState.value.sleepGraphData, Modifier.widthIn(max = 600.dp)) + if (windowSizeClass == WindowWidthSizeClass.Compact) { + AverageTimeInBedCard() + AverageTimeAsleepCard() + } else { + FlowColumn { + AverageTimeInBedCard() + AverageTimeAsleepCard() + } + } + if (windowSizeClass == WindowWidthSizeClass.Compact) { + WellnessCard( + wellnessData = uiState.value.wellnessData, + modifier = Modifier + .widthIn(max = 400.dp) + .heightIn(min = 200.dp), + ) + HeartRateCard( + modifier = Modifier.widthIn(max = 400.dp, min = 200.dp), + uiState.value.heartRateData, + ) + } else { + FlowColumn { + WellnessCard( + wellnessData = uiState.value.wellnessData, + modifier = Modifier + .widthIn(max = 400.dp) + .heightIn(min = 200.dp), + ) + HeartRateCard( + modifier = Modifier.widthIn(max = 400.dp, min = 200.dp), + uiState.value.heartRateData, + ) + } + } + } + } +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/MainActivity.kt b/JetLagged/app/src/main/java/com/example/jetlagged/MainActivity.kt new file mode 100644 index 0000000000..aa07582086 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/MainActivity.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged + +import android.content.res.Configuration +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import com.example.jetlagged.ui.theme.JetLaggedTheme + +class MainActivity : ComponentActivity() { + + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + val windowSizeClass = calculateWindowSizeClass(this) + JetLaggedTheme { + HomeScreenDrawer(windowSizeClass) + } + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // Changing the theme doesn't recreate the activity, so set the E2E values again + enableEdgeToEdge() + } +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/BubbleBackground.kt b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/BubbleBackground.kt new file mode 100644 index 0000000000..b7016dd0a8 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/BubbleBackground.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.backgrounds + +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import kotlin.random.Random + +@Composable +fun BubbleBackground(modifier: Modifier = Modifier, numberBubbles: Int, bubbleColor: Color) { + val infiniteAnimation = rememberInfiniteTransition(label = "bubble position") + + Box(modifier = modifier) { + val bubbles = remember(numberBubbles) { + List(numberBubbles) { + BackgroundBubbleData( + startPosition = Offset( + x = Random.nextFloat(), + y = Random.nextFloat(), + ), + endPosition = Offset( + x = Random.nextFloat(), + y = Random.nextFloat(), + ), + durationMillis = Random.nextLong(3000L, 10000L), + easingFunction = EaseInOut, + radius = Random.nextFloat() * 30.dp + 20.dp, + ) + } + } + for (bubble in bubbles) { + val xValue by infiniteAnimation.animateFloat( + initialValue = bubble.startPosition.x, + targetValue = bubble.endPosition.x, + animationSpec = infiniteRepeatable( + animation = tween( + bubble.durationMillis.toInt(), + easing = bubble.easingFunction, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "", + ) + val yValue by infiniteAnimation.animateFloat( + initialValue = bubble.startPosition.y, + targetValue = bubble.endPosition.y, + animationSpec = infiniteRepeatable( + animation = tween( + bubble.durationMillis.toInt(), + easing = bubble.easingFunction, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "", + ) + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle( + bubbleColor, + radius = bubble.radius.toPx(), + center = Offset(xValue * size.width, yValue * size.height), + ) + } + } + } +} + +data class BackgroundBubbleData( + val startPosition: Offset = Offset.Zero, + val endPosition: Offset = Offset.Zero, + val durationMillis: Long = 2000, + val easingFunction: Easing = EaseInOut, + val radius: Dp = 0.dp, +) diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/FadingCircleBackground.kt b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/FadingCircleBackground.kt new file mode 100644 index 0000000000..ef74b8dd4a --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/FadingCircleBackground.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.backgrounds + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.ceil + +@Composable +fun FadingCircleBackground(bubbleSize: Dp, color: Color) { + val alphaAnimation = remember { + Animatable(0.5f) + } + LaunchedEffect(Unit) { + alphaAnimation.animateTo( + 1f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse, + ), + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val bubbleSizePx = bubbleSize.toPx() + val paddingPx = 8.dp.toPx() + val numberCols = size.width / bubbleSizePx + val numberRows = size.height / bubbleSizePx + + onDrawBehind { + repeat(ceil(numberRows).toInt()) { row -> + repeat(ceil(numberCols).toInt()) { col -> + val offset = if (row.mod(2) == 0) + (bubbleSizePx + paddingPx) / 2f else 0f + drawCircle( + color.copy( + alpha = color.alpha * + ((row) / numberRows * alphaAnimation.value), + ), + radius = bubbleSizePx / 2f, + center = Offset( + (bubbleSizePx + paddingPx) * col + offset, + (bubbleSizePx + paddingPx) * row, + ), + ) + } + } + } + }, + ) +} + +@Preview +@Composable +fun FadingCirclePreview() { + FadingCircleBackground(bubbleSize = 30.dp, color = Color.Red) +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/SimpleGradientBackground.kt b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/SimpleGradientBackground.kt new file mode 100644 index 0000000000..2eebc76f49 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/SimpleGradientBackground.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.backgrounds + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Brush +import com.example.jetlagged.ui.theme.White +import com.example.jetlagged.ui.theme.Yellow +import com.example.jetlagged.ui.theme.YellowVariant + +fun Modifier.simpleGradient(): Modifier = drawWithCache { + val gradientBrush = Brush.verticalGradient(listOf(Yellow, YellowVariant, White)) + onDrawBehind { + drawRect(gradientBrush, alpha = 1f) + } +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/SolarFlareShaderBackground.kt b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/SolarFlareShaderBackground.kt new file mode 100644 index 0000000000..c0501b1088 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/SolarFlareShaderBackground.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.backgrounds + +import android.graphics.RuntimeShader +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.animation.core.withInfiniteAnimationFrameMillis +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import kotlinx.coroutines.launch +import org.intellij.lang.annotations.Language + +/** + * Background modifier that displays a custom shader for Android T and above and a linear gradient + * for older versions of Android + */ +fun Modifier.solarFlareShaderBackground(baseColor: Color, backgroundColor: Color): Modifier = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.then(SolarFlareShaderBackgroundElement(baseColor, backgroundColor)) + } else { + this.then(Modifier.simpleGradient()) + } + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private data class SolarFlareShaderBackgroundElement(val baseColor: Color, val backgroundColor: Color) : + ModifierNodeElement() { + override fun create() = SolarFlairShaderBackgroundNode(baseColor, backgroundColor) + override fun update(node: SolarFlairShaderBackgroundNode) { + node.updateColors(baseColor, backgroundColor) + } +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private class SolarFlairShaderBackgroundNode(baseColor: Color, backgroundColor: Color) : + Modifier.Node(), + DrawModifierNode { + private val shader = RuntimeShader(SHADER) + private val shaderBrush = ShaderBrush(shader) + private val time = mutableFloatStateOf(0f) + + init { + updateColors(baseColor, backgroundColor) + } + + fun updateColors(baseColor: Color, backgroundColor: Color) { + shader.setColorUniform( + "baseColor", + android.graphics.Color.valueOf( + baseColor.red, + baseColor.green, + baseColor.blue, + baseColor.alpha, + ), + ) + shader.setColorUniform( + "backgroundColor", + android.graphics.Color.valueOf( + backgroundColor.red, + backgroundColor.green, + backgroundColor.blue, + backgroundColor.alpha, + ), + ) + } + + override fun ContentDrawScope.draw() { + shader.setFloatUniform("resolution", size.width, size.height) + shader.setFloatUniform("time", time.floatValue) + + drawRect(shaderBrush) + drawContent() + } + + override fun onAttach() { + coroutineScope.launch { + while (isAttached) { + withInfiniteAnimationFrameMillis { + time.floatValue = it / 1000f + } + } + } + } +} + +@Language("AGSL") +private val SHADER = """ + uniform float2 resolution; + uniform float time; + layout(color) uniform half4 baseColor; + layout(color) uniform half4 backgroundColor; + + const int ITERATIONS = 2; + const float INTENSITY = 100.0; + const float TIME_MULTIPLIER = 0.25; + + float4 main(in float2 fragCoord) { + // Slow down the animation to be more soothing + float calculatedTime = time * TIME_MULTIPLIER; + + // Coords + float2 uv = fragCoord / resolution.xy; + float2 uvCalc = (uv * 5.0) - (INTENSITY * 2.0); + + // Values to adjust per iteration + float2 iterationChange = float2(uvCalc); + float colorPart = 1.0; + + for (int i = 0; i < ITERATIONS; i++) { + iterationChange = uvCalc + float2( + cos(calculatedTime + iterationChange.x) + + sin(calculatedTime - iterationChange.y), + cos(calculatedTime - iterationChange.x) + + sin(calculatedTime + iterationChange.y) + ); + colorPart += 0.8 / length( + float2(uvCalc.x / (cos(iterationChange.x + calculatedTime) * INTENSITY), + uvCalc.y / (sin(iterationChange.y + calculatedTime) * INTENSITY) + ) + ); + } + colorPart = 1.6 - (colorPart / float(ITERATIONS)); + + // Fade out the bottom on a curve + float mixRatio = 1.0 - (uv.y * uv.y); + // Mix calculated color with the incoming base color + float4 color = float4(colorPart * baseColor.r, colorPart * baseColor.g, colorPart * baseColor.b, 1.0); + // Mix color with the background + color = float4( + mix(backgroundColor.r, color.r, mixRatio), + mix(backgroundColor.g, color.g, mixRatio), + mix(backgroundColor.b, color.b, mixRatio), + 1.0 + ); + // Keep all channels within valid bounds of 0.0 and 1.0 + return clamp(color, 0.0, 1.0); + } +""".trimIndent() diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/StripesShaderBackground.kt b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/StripesShaderBackground.kt new file mode 100644 index 0000000000..bd82bf8efb --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/StripesShaderBackground.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.backgrounds + +import android.graphics.RuntimeShader +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.animation.core.withInfiniteAnimationFrameMillis +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import kotlinx.coroutines.launch +import org.intellij.lang.annotations.Language + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private data class MovingStripesBackgroundElement(val stripeColor: Color, val backgroundColor: Color) : + ModifierNodeElement() { + override fun create(): MovingStripesBackgroundNode = MovingStripesBackgroundNode(stripeColor, backgroundColor) + override fun update(node: MovingStripesBackgroundNode) { + node.updateColors(stripeColor, backgroundColor) + } +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private class MovingStripesBackgroundNode(stripeColor: Color, backgroundColor: Color) : + Modifier.Node(), + DrawModifierNode { + + private val shader = RuntimeShader(SHADER) + private val shaderBrush = ShaderBrush(shader) + private val time = mutableFloatStateOf(0f) + + init { + updateColors(stripeColor, backgroundColor) + } + + fun updateColors(stripeColor: Color, backgroundColor: Color) { + shader.setColorUniform( + "stripeColor", + android.graphics.Color.valueOf( + stripeColor.red, + stripeColor.green, + stripeColor.blue, + stripeColor.alpha, + ), + ) + shader.setFloatUniform("backgroundLuminance", backgroundColor.luminance()) + shader.setColorUniform( + "backgroundColor", + android.graphics.Color.valueOf( + backgroundColor.red, + backgroundColor.green, + backgroundColor.blue, + backgroundColor.alpha, + ), + ) + } + + override fun ContentDrawScope.draw() { + shader.setFloatUniform("resolution", size.width, size.height) + shader.setFloatUniform("time", time.floatValue) + + drawRect(shaderBrush) + + drawContent() + } + + override fun onAttach() { + coroutineScope.launch { + while (true) { + withInfiniteAnimationFrameMillis { + time.floatValue = it / 1000f + } + } + } + } +} + +fun Modifier.movingStripesBackground(stripeColor: Color, backgroundColor: Color): Modifier = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + this.then(MovingStripesBackgroundElement(stripeColor, backgroundColor)) + } else { + this.then(Modifier.simpleGradient()) + } + +@Language("AGSL") +private val SHADER = """ + uniform float2 resolution; + uniform float time; + uniform float backgroundLuminance; + layout(color) uniform half4 backgroundColor; + layout(color) uniform half4 stripeColor; + + float calculateColorMultiplier(float yCoord, float factor, bool fadeToDark) { + float result = step(yCoord, 1.0 + factor * 2.0) - step(yCoord, factor - 0.1); + if (fadeToDark) { + result *= -2.4; + } + return result; + } + + float4 main(in float2 fragCoord) { + // Config values + const float speedMultiplier = 1.5; + const float waveDensity = 1.0; + const float waves = 7.0; + const float waveCurveMultiplier = 4.3; + const float energyMultiplier = 0.1; + const float backgroundTolerance = 0.1; + + // Calculated values + float2 uv = fragCoord / resolution.xy; + float energy = waves * energyMultiplier; + float timeOffset = time * speedMultiplier; + float3 rgbColor = stripeColor.rgb; + float hAdjustment = uv.x * waveCurveMultiplier; + float loopMultiplier = 0.7 / waves; + float3 loopColor = vec3(1.0 - rgbColor.r, 1.0 - rgbColor.g, 1.0 - rgbColor.b) / waves; + bool fadeToDark = false; + if (backgroundLuminance < 0.5) { + fadeToDark = true; + } + float channelOffset = 0.0; + + for (float i = 1.0; i <= waves; i += 1.0) { + float loopFactor = i * loopMultiplier; + float sinInput = (timeOffset + hAdjustment) * energy; + float curve = sin(sinInput) * (1.0 - loopFactor) * 0.05; + float colorMultiplier = calculateColorMultiplier(uv.y, loopFactor, fadeToDark); + rgbColor += loopColor * colorMultiplier; + channelOffset += colorMultiplier; + + // Offset for next loop + uv.y += curve; + } + + // Clipped values are overridden to the passed in backgroundColor + if (fadeToDark) { + if (rgbColor.r <= backgroundTolerance && rgbColor.g <= backgroundTolerance && rgbColor.b <= backgroundTolerance) { + rgbColor = backgroundColor.rgb; + } + } else { + if (rgbColor.r >= (1.0 - backgroundTolerance) && rgbColor.g >= (1.0 - backgroundTolerance) && rgbColor.b >= (1.0 - backgroundTolerance)) { + rgbColor = backgroundColor.rgb; + } + } + return float4(rgbColor, 1.0); + } +""".trimIndent() diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/data/FakeHeartRateData.kt b/JetLagged/app/src/main/java/com/example/jetlagged/data/FakeHeartRateData.kt new file mode 100644 index 0000000000..9eddae491b --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/data/FakeHeartRateData.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.data + +import java.time.LocalTime + +data class HeartRateData(val date: LocalTime, val amount: Int) +internal val heartRateGraphData = listOf( + HeartRateData(LocalTime.of(0, 34), 55), + HeartRateData(LocalTime.of(0, 52), 145), + HeartRateData(LocalTime.of(0, 40), 99), + HeartRateData(LocalTime.of(0, 19), 72), + HeartRateData(LocalTime.of(0, 14), 150), + HeartRateData(LocalTime.of(1, 44), 95), + HeartRateData(LocalTime.of(1, 58), 105), + HeartRateData(LocalTime.of(1, 21), 170), + HeartRateData(LocalTime.of(1, 49), 152), + HeartRateData(LocalTime.of(1, 31), 55), + HeartRateData(LocalTime.of(1, 20), 158), + HeartRateData(LocalTime.of(1, 41), 67), + HeartRateData(LocalTime.of(1, 21), 65), + HeartRateData(LocalTime.of(2, 4), 159), + HeartRateData(LocalTime.of(2, 19), 174), + HeartRateData(LocalTime.of(2, 19), 117), + HeartRateData(LocalTime.of(2, 0), 84), + HeartRateData(LocalTime.of(2, 33), 152), + HeartRateData(LocalTime.of(2, 4), 162), + HeartRateData(LocalTime.of(3, 11), 55), + HeartRateData(LocalTime.of(3, 22), 93), + HeartRateData(LocalTime.of(3, 39), 133), + HeartRateData(LocalTime.of(3, 15), 173), + HeartRateData(LocalTime.of(3, 7), 172), + HeartRateData(LocalTime.of(4, 8), 93), + HeartRateData(LocalTime.of(4, 27), 148), + HeartRateData(LocalTime.of(4, 8), 153), + HeartRateData(LocalTime.of(4, 47), 170), + HeartRateData(LocalTime.of(4, 11), 60), + HeartRateData(LocalTime.of(4, 46), 100), + HeartRateData(LocalTime.of(4, 15), 175), + HeartRateData(LocalTime.of(5, 39), 133), + HeartRateData(LocalTime.of(5, 16), 98), + HeartRateData(LocalTime.of(5, 59), 80), + HeartRateData(LocalTime.of(5, 17), 122), + HeartRateData(LocalTime.of(5, 55), 144), + HeartRateData(LocalTime.of(5, 5), 101), + HeartRateData(LocalTime.of(5, 3), 141), + HeartRateData(LocalTime.of(5, 10), 153), + HeartRateData(LocalTime.of(5, 17), 135), + HeartRateData(LocalTime.of(6, 28), 117), + HeartRateData(LocalTime.of(6, 22), 153), + HeartRateData(LocalTime.of(6, 38), 103), + HeartRateData(LocalTime.of(9, 6), 92), + HeartRateData(LocalTime.of(9, 15), 141), + HeartRateData(LocalTime.of(9, 22), 120), + HeartRateData(LocalTime.of(10, 50), 125), + HeartRateData(LocalTime.of(10, 4), 109), + HeartRateData(LocalTime.of(10, 59), 174), + HeartRateData(LocalTime.of(10, 11), 115), + HeartRateData(LocalTime.of(10, 13), 92), + HeartRateData(LocalTime.of(10, 4), 127), + HeartRateData(LocalTime.of(10, 8), 62), + HeartRateData(LocalTime.of(10, 9), 129), + HeartRateData(LocalTime.of(11, 7), 128), + HeartRateData(LocalTime.of(11, 44), 67), + HeartRateData(LocalTime.of(11, 10), 130), + HeartRateData(LocalTime.of(11, 12), 153), + HeartRateData(LocalTime.of(11, 5), 133), + HeartRateData(LocalTime.of(11, 31), 174), + HeartRateData(LocalTime.of(11, 45), 91), + HeartRateData(LocalTime.of(11, 9), 95), + HeartRateData(LocalTime.of(11, 4), 102), + HeartRateData(LocalTime.of(11, 46), 147), + HeartRateData(LocalTime.of(11, 48), 145), + HeartRateData(LocalTime.of(11, 44), 131), + HeartRateData(LocalTime.of(12, 40), 159), + HeartRateData(LocalTime.of(12, 14), 150), + HeartRateData(LocalTime.of(12, 37), 118), + HeartRateData(LocalTime.of(12, 38), 134), + HeartRateData(LocalTime.of(12, 53), 168), + HeartRateData(LocalTime.of(12, 11), 143), + HeartRateData(LocalTime.of(12, 47), 110), + HeartRateData(LocalTime.of(12, 21), 116), + HeartRateData(LocalTime.of(12, 13), 145), + HeartRateData(LocalTime.of(13, 37), 56), + HeartRateData(LocalTime.of(13, 9), 132), + HeartRateData(LocalTime.of(13, 6), 98), + HeartRateData(LocalTime.of(13, 22), 134), + HeartRateData(LocalTime.of(13, 25), 125), + HeartRateData(LocalTime.of(13, 47), 101), + HeartRateData(LocalTime.of(13, 50), 138), + HeartRateData(LocalTime.of(13, 47), 59), + HeartRateData(LocalTime.of(13, 55), 105), + HeartRateData(LocalTime.of(14, 56), 73), + HeartRateData(LocalTime.of(14, 7), 67), + HeartRateData(LocalTime.of(14, 33), 118), + HeartRateData(LocalTime.of(14, 50), 169), + HeartRateData(LocalTime.of(14, 2), 125), + HeartRateData(LocalTime.of(14, 16), 93), + HeartRateData(LocalTime.of(14, 7), 80), + HeartRateData(LocalTime.of(14, 1), 129), + HeartRateData(LocalTime.of(14, 59), 142), + HeartRateData(LocalTime.of(15, 5), 62), + HeartRateData(LocalTime.of(15, 55), 132), + HeartRateData(LocalTime.of(15, 41), 145), + HeartRateData(LocalTime.of(15, 41), 107), + HeartRateData(LocalTime.of(15, 45), 110), + HeartRateData(LocalTime.of(16, 52), 97), + HeartRateData(LocalTime.of(16, 16), 127), + HeartRateData(LocalTime.of(16, 0), 155), + HeartRateData(LocalTime.of(16, 35), 75), + HeartRateData(LocalTime.of(16, 18), 170), + HeartRateData(LocalTime.of(16, 6), 68), + HeartRateData(LocalTime.of(16, 12), 63), + HeartRateData(LocalTime.of(16, 2), 162), + HeartRateData(LocalTime.of(16, 40), 146), + HeartRateData(LocalTime.of(16, 26), 70), + HeartRateData(LocalTime.of(16, 32), 121), + HeartRateData(LocalTime.of(17, 49), 87), + HeartRateData(LocalTime.of(17, 42), 54), + HeartRateData(LocalTime.of(17, 12), 169), + HeartRateData(LocalTime.of(17, 24), 154), + HeartRateData(LocalTime.of(17, 4), 75), + HeartRateData(LocalTime.of(17, 51), 104), + HeartRateData(LocalTime.of(17, 53), 114), + HeartRateData(LocalTime.of(17, 14), 93), + HeartRateData(LocalTime.of(17, 35), 146), + HeartRateData(LocalTime.of(17, 19), 101), + HeartRateData(LocalTime.of(17, 27), 130), + HeartRateData(LocalTime.of(17, 2), 56), + HeartRateData(LocalTime.of(17, 27), 55), + HeartRateData(LocalTime.of(17, 31), 73), + HeartRateData(LocalTime.of(18, 59), 103), + HeartRateData(LocalTime.of(18, 10), 95), + HeartRateData(LocalTime.of(18, 28), 120), + HeartRateData(LocalTime.of(18, 5), 88), + HeartRateData(LocalTime.of(18, 44), 63), + HeartRateData(LocalTime.of(18, 16), 124), + HeartRateData(LocalTime.of(18, 14), 120), + HeartRateData(LocalTime.of(18, 18), 121), + HeartRateData(LocalTime.of(18, 53), 167), + HeartRateData(LocalTime.of(18, 45), 110), + HeartRateData(LocalTime.of(19, 19), 170), + HeartRateData(LocalTime.of(19, 59), 85), + HeartRateData(LocalTime.of(19, 4), 84), + HeartRateData(LocalTime.of(19, 8), 111), + HeartRateData(LocalTime.of(19, 54), 75), + HeartRateData(LocalTime.of(20, 36), 122), + HeartRateData(LocalTime.of(20, 21), 153), + HeartRateData(LocalTime.of(20, 11), 82), + HeartRateData(LocalTime.of(20, 19), 152), + HeartRateData(LocalTime.of(20, 26), 56), + HeartRateData(LocalTime.of(20, 21), 63), + HeartRateData(LocalTime.of(20, 22), 90), + HeartRateData(LocalTime.of(20, 20), 172), + HeartRateData(LocalTime.of(20, 56), 78), + HeartRateData(LocalTime.of(21, 52), 65), + HeartRateData(LocalTime.of(21, 46), 106), + HeartRateData(LocalTime.of(21, 57), 129), + HeartRateData(LocalTime.of(21, 31), 105), + HeartRateData(LocalTime.of(21, 39), 138), + HeartRateData(LocalTime.of(21, 0), 93), + HeartRateData(LocalTime.of(21, 20), 67), + HeartRateData(LocalTime.of(21, 47), 166), + HeartRateData(LocalTime.of(21, 10), 136), + HeartRateData(LocalTime.of(21, 26), 90), + HeartRateData(LocalTime.of(21, 56), 83), + HeartRateData(LocalTime.of(21, 9), 72), + HeartRateData(LocalTime.of(21, 38), 87), + HeartRateData(LocalTime.of(22, 15), 149), + HeartRateData(LocalTime.of(22, 25), 176), + HeartRateData(LocalTime.of(22, 13), 77), + HeartRateData(LocalTime.of(22, 53), 159), + HeartRateData(LocalTime.of(22, 20), 81), + HeartRateData(LocalTime.of(22, 48), 150), + HeartRateData(LocalTime.of(22, 1), 123), + HeartRateData(LocalTime.of(22, 19), 130), + HeartRateData(LocalTime.of(23, 27), 147), + HeartRateData(LocalTime.of(23, 59), 126), + HeartRateData(LocalTime.of(23, 22), 142), + HeartRateData(LocalTime.of(23, 48), 114), + HeartRateData(LocalTime.of(23, 51), 93), + HeartRateData(LocalTime.of(23, 46), 65), + HeartRateData(LocalTime.of(23, 21), 63), + HeartRateData(LocalTime.of(23, 59), 95), +).sortedBy { it.date.toSecondOfDay() } + +const val numberEntries = 48 // 48 blocks of 30 minutes +const val bracketInSeconds = 30 * 60 // 30 minutes time frame diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/data/FakeSleepData.kt b/JetLagged/app/src/main/java/com/example/jetlagged/data/FakeSleepData.kt new file mode 100644 index 0000000000..121504d1e3 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/data/FakeSleepData.kt @@ -0,0 +1,647 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.data + +import com.example.jetlagged.sleep.SleepDayData +import com.example.jetlagged.sleep.SleepGraphData +import com.example.jetlagged.sleep.SleepPeriod +import com.example.jetlagged.sleep.SleepType +import java.time.LocalDateTime + +// In the real world, you should get this data from a backend. +val sleepData = SleepGraphData( + listOf( + SleepDayData( + LocalDateTime.now().minusDays(7), + listOf( + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(7) + .withHour(21) + .withMinute(8), + endTime = LocalDateTime.now() + .minusDays(7) + .withHour(21) + .withMinute(40), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(7) + .withHour(21) + .withMinute(40), + endTime = LocalDateTime.now() + .minusDays(7) + .withHour(22) + .withMinute(20), + type = SleepType.Light, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(7) + .withHour(22) + .withMinute(20), + endTime = LocalDateTime.now() + .minusDays(7) + .withHour(22) + .withMinute(50), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(7) + .withHour(22) + .withMinute(50), + endTime = LocalDateTime.now() + .minusDays(7) + .withHour(23) + .withMinute(30), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(7) + .withHour(23) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(6) + .withHour(1) + .withMinute(10), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(6) + .withHour(1) + .withMinute(10), + endTime = LocalDateTime.now() + .minusDays(6) + .withHour(2) + .withMinute(30), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(6) + .withHour(2) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(6) + .withHour(4) + .withMinute(10), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(6) + .withHour(4) + .withMinute(10), + endTime = LocalDateTime.now() + .minusDays(6) + .withHour(5) + .withMinute(30), + type = SleepType.Awake, + ), + ), + sleepScore = 90, + ), + SleepDayData( + LocalDateTime.now().minusDays(6), + listOf( + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(6) + .withHour(22) + .withMinute(38), + endTime = LocalDateTime.now() + .minusDays(6) + .withHour(22) + .withMinute(50), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(6) + .withHour(22) + .withMinute(50), + endTime = LocalDateTime.now() + .minusDays(6) + .withHour(23) + .withMinute(30), + type = SleepType.Light, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(6) + .withHour(23) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(6) + .withHour(23) + .withMinute(55), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(6) + .withHour(23) + .withMinute(55), + endTime = LocalDateTime.now() + .minusDays(5) + .withHour(2) + .withMinute(40), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(5) + .withHour(2) + .withMinute(40), + endTime = LocalDateTime.now() + .minusDays(5) + .withHour(2) + .withMinute(50), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(5) + .withHour(2) + .withMinute(50), + endTime = LocalDateTime.now() + .minusDays(5) + .withHour(4) + .withMinute(12), + type = SleepType.Deep, + ), + ), + sleepScore = 70, + ), + SleepDayData( + LocalDateTime.now().minusDays(5), + listOf( + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(5) + .withHour(22) + .withMinute(8), + endTime = LocalDateTime.now() + .minusDays(5) + .withHour(22) + .withMinute(40), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(5) + .withHour(22) + .withMinute(40), + endTime = LocalDateTime.now() + .minusDays(5) + .withHour(22) + .withMinute(50), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(5) + .withHour(22) + .withMinute(50), + endTime = LocalDateTime.now() + .minusDays(5) + .withHour(22) + .withMinute(55), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(5) + .withHour(22) + .withMinute(55), + endTime = LocalDateTime.now() + .minusDays(5) + .withHour(23) + .withMinute(30), + type = SleepType.Light, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(5) + .withHour(23) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(4) + .withHour(1) + .withMinute(10), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(4) + .withHour(1) + .withMinute(10), + endTime = LocalDateTime.now() + .minusDays(4) + .withHour(2) + .withMinute(30), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(4) + .withHour(2) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(4) + .withHour(3) + .withMinute(5), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(4) + .withHour(3) + .withMinute(5), + endTime = LocalDateTime.now() + .minusDays(4) + .withHour(4) + .withMinute(50), + type = SleepType.Light, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(4) + .withHour(4) + .withMinute(50), + endTime = LocalDateTime.now() + .minusDays(4) + .withHour(6) + .withMinute(30), + type = SleepType.REM, + ), + ), + sleepScore = 60, + ), + SleepDayData( + LocalDateTime.now().minusDays(4), + listOf( + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(4) + .withHour(20) + .withMinute(20), + endTime = LocalDateTime.now() + .minusDays(4) + .withHour(22) + .withMinute(40), + type = SleepType.Light, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(4) + .withHour(22) + .withMinute(40), + endTime = LocalDateTime.now() + .minusDays(4) + .withHour(22) + .withMinute(50), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(4) + .withHour(22) + .withMinute(50), + endTime = LocalDateTime.now() + .minusDays(4) + .withHour(23) + .withMinute(55), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(4) + .withHour(23) + .withMinute(55), + endTime = LocalDateTime.now() + .minusDays(3) + .withHour(1) + .withMinute(33), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(3) + .withHour(1) + .withMinute(33), + endTime = LocalDateTime.now() + .minusDays(3) + .withHour(2) + .withMinute(30), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(3) + .withHour(2) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(3) + .withHour(3) + .withMinute(45), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(3) + .withHour(3) + .withMinute(45), + endTime = LocalDateTime.now() + .minusDays(3) + .withHour(7) + .withMinute(15), + type = SleepType.Light, + ), + ), + sleepScore = 90, + ), + SleepDayData( + LocalDateTime.now().minusDays(3), + listOf( + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(3) + .withHour(22) + .withMinute(50), + endTime = LocalDateTime.now() + .minusDays(3) + .withHour(23) + .withMinute(30), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(3) + .withHour(23) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(0) + .withMinute(10), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(0) + .withMinute(10), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(1) + .withMinute(10), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(1) + .withMinute(10), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(2) + .withMinute(30), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(2) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(4) + .withMinute(30), + type = SleepType.Light, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(4) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(4) + .withMinute(45), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(4) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(4) + .withMinute(45), + type = SleepType.REM, + ), + ), + sleepScore = 40, + ), + SleepDayData( + LocalDateTime.now().minusDays(2), + listOf( + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(20) + .withMinute(40), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(21) + .withMinute(40), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(21) + .withMinute(40), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(22) + .withMinute(20), + type = SleepType.Light, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(22) + .withMinute(20), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(22) + .withMinute(50), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(22) + .withMinute(50), + endTime = LocalDateTime.now() + .minusDays(2) + .withHour(23) + .withMinute(30), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(2) + .withHour(23) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(1) + .withHour(1) + .withMinute(10), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(1) + .withHour(1) + .withMinute(10), + endTime = LocalDateTime.now() + .minusDays(1) + .withHour(2) + .withMinute(30), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(1) + .withHour(2) + .withMinute(30), + endTime = LocalDateTime.now() + .minusDays(1) + .withHour(4) + .withMinute(10), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(1) + .withHour(4) + .withMinute(10), + endTime = LocalDateTime.now() + .minusDays(1) + .withHour(5) + .withMinute(30), + type = SleepType.Awake, + ), + ), + sleepScore = 82, + ), + SleepDayData( + LocalDateTime.now().minusDays(1), + listOf( + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(1) + .withHour(22) + .withMinute(8), + endTime = LocalDateTime.now() + .minusDays(1) + .withHour(22) + .withMinute(40), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(1) + .withHour(22) + .withMinute(40), + endTime = LocalDateTime.now() + .minusDays(1) + .withHour(22) + .withMinute(50), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(1) + .withHour(22) + .withMinute(50), + endTime = LocalDateTime.now() + .minusDays(1) + .withHour(22) + .withMinute(55), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(1) + .withHour(22) + .withMinute(55), + endTime = LocalDateTime.now() + .minusDays(1) + .withHour(23) + .withMinute(30), + type = SleepType.REM, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .minusDays(1) + .withHour(23) + .withMinute(30), + endTime = LocalDateTime.now() + .withHour(1) + .withMinute(10), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .withHour(1) + .withMinute(10), + endTime = LocalDateTime.now() + .withHour(2) + .withMinute(30), + type = SleepType.Awake, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .withHour(2) + .withMinute(30), + endTime = LocalDateTime.now() + .withHour(3) + .withMinute(5), + type = SleepType.Deep, + ), + SleepPeriod( + startTime = LocalDateTime.now() + .withHour(3) + .withMinute(5), + endTime = LocalDateTime.now() + .withHour(4) + .withMinute(50), + type = SleepType.Light, + ), + ), + sleepScore = 70, + ), + ), +) diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/data/JetLaggedHomeScreenState.kt b/JetLagged/app/src/main/java/com/example/jetlagged/data/JetLaggedHomeScreenState.kt new file mode 100644 index 0000000000..3716131e9f --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/data/JetLaggedHomeScreenState.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.data + +import com.example.jetlagged.sleep.SleepGraphData + +data class JetLaggedHomeScreenState( + val sleepGraphData: SleepGraphData = sleepData, + val wellnessData: WellnessData = WellnessData(10, 4, 5), + val heartRateData: HeartRateOverallData = HeartRateOverallData(), +) + +data class WellnessData(val snoring: Int, val coughing: Int, val respiration: Int) + +data class HeartRateOverallData(val averageBpm: Int = 65, val listData: List = heartRateGraphData) diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/data/JetLaggedHomeScreenViewModel.kt b/JetLagged/app/src/main/java/com/example/jetlagged/data/JetLaggedHomeScreenViewModel.kt new file mode 100644 index 0000000000..966e82c726 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/data/JetLaggedHomeScreenViewModel.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.data + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class JetLaggedHomeScreenViewModel : ViewModel() { + + val uiState: StateFlow = MutableStateFlow(JetLaggedHomeScreenState()) +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/heartrate/HeartRateCard.kt b/JetLagged/app/src/main/java/com/example/jetlagged/heartrate/HeartRateCard.kt new file mode 100644 index 0000000000..0e5d7b3be6 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/heartrate/HeartRateCard.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.heartrate + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetlagged.BasicInformationalCard +import com.example.jetlagged.HomeScreenCardHeading +import com.example.jetlagged.R +import com.example.jetlagged.data.HeartRateOverallData +import com.example.jetlagged.ui.theme.JetLaggedTheme +import com.example.jetlagged.ui.theme.SmallHeadingStyle +import com.example.jetlagged.ui.theme.TitleStyle + +@Preview +@Composable +fun HeartRateCard(modifier: Modifier = Modifier, heartRateData: HeartRateOverallData = HeartRateOverallData()) { + BasicInformationalCard( + borderColor = JetLaggedTheme.extraColors.heart, + modifier = modifier + .height(260.dp), + ) { + Column( + modifier = Modifier + .fillMaxSize(), + + ) { + HomeScreenCardHeading(text = stringResource(R.string.heart_rate_heading)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.Center, + ) { + Text( + heartRateData.averageBpm.toString(), + style = TitleStyle, + modifier = Modifier.alignByBaseline(), + textAlign = TextAlign.Center, + ) + Text( + "bpm", + modifier = Modifier.alignByBaseline(), + style = SmallHeadingStyle, + ) + } + HeartRateGraph(heartRateData.listData) + } + } +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/heartrate/HeartRateGraph.kt b/JetLagged/app/src/main/java/com/example/jetlagged/heartrate/HeartRateGraph.kt new file mode 100644 index 0000000000..1f80e661e6 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/heartrate/HeartRateGraph.kt @@ -0,0 +1,246 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.heartrate + +import android.graphics.PointF +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toIntRect +import androidx.compose.ui.unit.toSize +import com.example.jetlagged.data.HeartRateData +import com.example.jetlagged.data.bracketInSeconds +import com.example.jetlagged.data.heartRateGraphData +import com.example.jetlagged.data.numberEntries +import com.example.jetlagged.ui.theme.JetLaggedTheme +import kotlin.math.roundToInt + +@Composable +fun HeartRateGraph(listData: List) { + Box(Modifier.size(width = 400.dp, height = 100.dp)) { + Graph( + listData = listData, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Composable +private fun Graph( + listData: List, + modifier: Modifier = Modifier, + waveLineColors: List = JetLaggedTheme.extraColors.heartWave, + pathBackground: Color = JetLaggedTheme.extraColors.heartWaveBackground, +) { + if (waveLineColors.size < 2) { + throw IllegalArgumentException("waveLineColors requires 2+ colors; $waveLineColors") + } + Box( + modifier + .fillMaxSize() + .drawWithCache { + val paths = generateSmoothPath(listData, size) + val lineBrush = Brush.verticalGradient(waveLineColors) + onDrawBehind { + drawPath( + paths.second, + pathBackground, + style = Fill, + ) + drawPath( + paths.first, + lineBrush, + style = Stroke(2.dp.toPx()), + ) + } + }, + ) +} + +sealed class DataPoint { + object NoMeasurement : DataPoint() + data class Measurement(val averageMeasurementTime: Int, val minHeartRate: Int, val maxHeartRate: Int, val averageHeartRate: Int) : + DataPoint() +} + +fun generateSmoothPath(data: List, size: Size): Pair { + val path = Path() + val variancePath = Path() + + val totalSeconds = 60 * 60 * 24 // total seconds in a day + val widthPerSecond = size.width / totalSeconds + val maxValue = data.maxBy { it.amount }.amount + val minValue = data.minBy { it.amount }.amount + val graphTop = ((maxValue + 5) / 10f).roundToInt() * 10 + val graphBottom = (minValue / 10f).toInt() * 10 + val range = graphTop - graphBottom + val heightPxPerAmount = size.height / range.toFloat() + + var previousX = 0f + var previousY = size.height + var previousMaxX = 0f + var previousMaxY = size.height + val groupedMeasurements = (0..numberEntries).map { bracketStart -> + heartRateGraphData.filter { + (bracketStart * bracketInSeconds..(bracketStart + 1) * bracketInSeconds) + .contains(it.date.toSecondOfDay()) + } + }.map { heartRates -> + if (heartRates.isEmpty()) DataPoint.NoMeasurement else + DataPoint.Measurement( + averageMeasurementTime = heartRates.map { it.date.toSecondOfDay() }.average() + .roundToInt(), + minHeartRate = heartRates.minBy { it.amount }.amount, + maxHeartRate = heartRates.maxBy { it.amount }.amount, + averageHeartRate = heartRates.map { it.amount }.average().roundToInt(), + ) + } + groupedMeasurements.forEachIndexed { i, dataPoint -> + if (i == 0 && dataPoint is DataPoint.Measurement) { + path.moveTo( + 0f, + size.height - (dataPoint.averageHeartRate - graphBottom).toFloat() * + heightPxPerAmount, + ) + variancePath.moveTo( + 0f, + size.height - (dataPoint.maxHeartRate - graphBottom).toFloat() * + heightPxPerAmount, + ) + } + + if (dataPoint is DataPoint.Measurement) { + val x = dataPoint.averageMeasurementTime * widthPerSecond + val y = size.height - (dataPoint.averageHeartRate - graphBottom).toFloat() * + heightPxPerAmount + + // to do smooth curve graph - we use cubicTo, uncomment section below for non-curve + val controlPoint1 = PointF((x + previousX) / 2f, previousY) + val controlPoint2 = PointF((x + previousX) / 2f, y) + path.cubicTo( + controlPoint1.x, controlPoint1.y, controlPoint2.x, controlPoint2.y, + x, y, + ) + previousX = x + previousY = y + + val maxX = dataPoint.averageMeasurementTime * widthPerSecond + val maxY = size.height - (dataPoint.maxHeartRate - graphBottom).toFloat() * + heightPxPerAmount + val maxControlPoint1 = PointF((maxX + previousMaxX) / 2f, previousMaxY) + val maxControlPoint2 = PointF((maxX + previousMaxX) / 2f, maxY) + variancePath.cubicTo( + maxControlPoint1.x, maxControlPoint1.y, maxControlPoint2.x, maxControlPoint2.y, + maxX, maxY, + ) + + previousMaxX = maxX + previousMaxY = maxY + } + } + + var previousMinX = size.width + var previousMinY = size.height + groupedMeasurements.reversed().forEachIndexed { index, dataPoint -> + val i = 47 - index + if (i == 47 && dataPoint is DataPoint.Measurement) { + variancePath.moveTo( + size.width, + size.height - (dataPoint.minHeartRate - graphBottom).toFloat() * + heightPxPerAmount, + ) + } + + if (dataPoint is DataPoint.Measurement) { + val minX = dataPoint.averageMeasurementTime * widthPerSecond + val minY = size.height - (dataPoint.minHeartRate - graphBottom).toFloat() * + heightPxPerAmount + val minControlPoint1 = PointF((minX + previousMinX) / 2f, previousMinY) + val minControlPoint2 = PointF((minX + previousMinX) / 2f, minY) + variancePath.cubicTo( + minControlPoint1.x, minControlPoint1.y, minControlPoint2.x, minControlPoint2.y, + minX, minY, + ) + + previousMinX = minX + previousMinY = minY + } + } + return path to variancePath +} + +fun DrawScope.drawHighlight(highlightedWeek: Int, graphData: List, textMeasurer: TextMeasurer, labelTextStyle: TextStyle) { + val amount = graphData[highlightedWeek].amount + val minAmount = graphData.minBy { it.amount }.amount + val range = graphData.maxBy { it.amount }.amount - minAmount + val percentageHeight = ((amount - minAmount).toFloat() / range.toFloat()) + val pointY = size.height - (size.height * percentageHeight) + // draw vertical line on week + val x = highlightedWeek * (size.width / (graphData.size - 1)) + drawLine( + HighlightColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = 2.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)), + ) + + // draw hit circle on graph + drawCircle( + Color.Green, + radius = 4.dp.toPx(), + center = Offset(x, pointY), + ) + + // draw info box + val textLayoutResult = textMeasurer.measure("$amount", style = labelTextStyle) + val highlightContainerSize = (textLayoutResult.size).toIntRect().inflate(4.dp.roundToPx()).size + val boxTopLeft = (x - (highlightContainerSize.width / 2f)) + .coerceIn(0f, size.width - highlightContainerSize.width) + drawRoundRect( + Color.White, + topLeft = Offset(boxTopLeft, 0f), + size = highlightContainerSize.toSize(), + cornerRadius = CornerRadius(8.dp.toPx()), + ) + drawText( + textLayoutResult, + color = Color.Black, + topLeft = Offset(boxTopLeft + 4.dp.toPx(), 4.dp.toPx()), + ) +} + +val BarColor = Color.White.copy(alpha = 0.3f) +val HighlightColor = Color.White.copy(alpha = 0.7f) diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedHeader.kt b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedHeader.kt new file mode 100644 index 0000000000..5e9c73cd6c --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedHeader.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.sleep + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetlagged.R +import com.example.jetlagged.ui.theme.TitleBarStyle + +@Preview +@Composable +fun JetLaggedHeader(modifier: Modifier = Modifier, onDrawerClicked: () -> Unit = {}) { + Box( + modifier.height(150.dp), + ) { + Row(modifier = Modifier.windowInsetsPadding(insets = WindowInsets.systemBars)) { + IconButton( + onClick = onDrawerClicked, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_menu), + contentDescription = stringResource(R.string.not_implemented), + ) + } + + Text( + stringResource(R.string.jetlagged_app_heading), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + style = TitleBarStyle, + textAlign = TextAlign.Start, + ) + } + } +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedHeaderTabs.kt b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedHeaderTabs.kt new file mode 100644 index 0000000000..ba4a62fa19 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedHeaderTabs.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.sleep + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabPosition +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.jetlagged.R +import com.example.jetlagged.ui.theme.SmallHeadingStyle + +enum class SleepTab(val title: Int) { + Day(R.string.sleep_tab_day_heading), + Week(R.string.sleep_tab_week_heading), + Month(R.string.sleep_tab_month_heading), + SixMonths(R.string.sleep_tab_six_months_heading), + OneYear(R.string.sleep_tab_one_year_heading), +} + +@Composable +fun JetLaggedHeaderTabs(onTabSelected: (SleepTab) -> Unit, selectedTab: SleepTab, modifier: Modifier = Modifier) { + PrimaryScrollableTabRow( + modifier = modifier, + edgePadding = 12.dp, + selectedTabIndex = selectedTab.ordinal, + indicator = { + Box( + Modifier + .tabIndicatorOffset(selectedTab.ordinal, matchContentSize = true) + .fillMaxSize() + .padding(horizontal = 2.dp) + .border( + BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + RoundedCornerShape(10.dp), + ), + ) + }, + divider = { }, + ) { + SleepTab.entries.forEachIndexed { index, sleepTab -> + val selected = index == selectedTab.ordinal + SleepTabText( + sleepTab = sleepTab, + selected = selected, + onTabSelected = onTabSelected, + index = index, + ) + } + } +} + +private val textModifier = Modifier + .padding(vertical = 6.dp, horizontal = 4.dp) +@Composable +private fun SleepTabText(sleepTab: SleepTab, selected: Boolean, index: Int, onTabSelected: (SleepTab) -> Unit) { + Tab( + modifier = Modifier + .padding(horizontal = 2.dp) + .clip(RoundedCornerShape(16.dp)), + selected = selected, + unselectedContentColor = MaterialTheme.colorScheme.onBackground, + selectedContentColor = MaterialTheme.colorScheme.onBackground, + onClick = { + onTabSelected(SleepTab.entries[index]) + }, + ) { + Text( + modifier = textModifier, + text = stringResource(id = sleepTab.title), + style = SmallHeadingStyle, + ) + } +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedTimeGraph.kt b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedTimeGraph.kt new file mode 100644 index 0000000000..e7393f417e --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedTimeGraph.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.sleep + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.jetlagged.BasicInformationalCard +import com.example.jetlagged.HomeScreenCardHeading +import com.example.jetlagged.ui.theme.JetLaggedTheme +import com.example.jetlagged.ui.theme.SmallHeadingStyle +import java.time.DayOfWeek +import java.time.format.TextStyle +import java.util.Locale + +@Composable +fun JetLaggedSleepGraphCard(sleepState: SleepGraphData, modifier: Modifier = Modifier) { + var selectedTab by remember { mutableStateOf(SleepTab.Week) } + + BasicInformationalCard( + borderColor = MaterialTheme.colorScheme.primary, + modifier = modifier, + ) { + Column { + HomeScreenCardHeading(text = "Sleep") + JetLaggedHeaderTabs( + onTabSelected = { selectedTab = it }, + selectedTab = selectedTab, + modifier = Modifier.padding(top = 16.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + JetLaggedTimeGraph( + sleepState, + ) + } + } +} + +@Composable +private fun JetLaggedTimeGraph(sleepGraphData: SleepGraphData, modifier: Modifier = Modifier) { + val scrollState = rememberScrollState() + + val hours = (sleepGraphData.earliestStartHour..23) + (0..sleepGraphData.latestEndHour) + + TimeGraph( + modifier = modifier + .horizontalScroll(scrollState) + .wrapContentSize(), + dayItemsCount = sleepGraphData.sleepDayData.size, + hoursHeader = { + HoursHeader(hours) + }, + dayLabel = { index -> + val data = sleepGraphData.sleepDayData[index] + DayLabel(data.startDate.dayOfWeek) + }, + bar = { index -> + val data = sleepGraphData.sleepDayData[index] + // We have access to Modifier.timeGraphBar() as we are now in TimeGraphScope + SleepBar( + sleepData = data, + modifier = Modifier + .padding(bottom = 8.dp) + .timeGraphBar( + start = data.firstSleepStart, + end = data.lastSleepEnd, + hours = hours, + ), + ) + }, + ) +} + +@Composable +private fun DayLabel(dayOfWeek: DayOfWeek) { + Text( + dayOfWeek.getDisplayName( + TextStyle.SHORT, LocalLocale.current.platformLocale, + ), + Modifier + .height(24.dp) + .padding(start = 8.dp, end = 24.dp), + style = SmallHeadingStyle, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun HoursHeader(hours: List) { + val brushColors = listOf( + JetLaggedTheme.extraColors.sleepChartPrimary, + JetLaggedTheme.extraColors.sleepChartSecondary, + ) + Row( + Modifier + .padding(bottom = 16.dp) + .drawBehind { + val brush = Brush.linearGradient(brushColors) + drawRoundRect( + brush, + cornerRadius = CornerRadius(10.dp.toPx(), 10.dp.toPx()), + ) + }, + ) { + hours.forEach { + Text( + text = "$it", + textAlign = TextAlign.Center, + modifier = Modifier + .width(50.dp) + .padding(vertical = 4.dp), + style = SmallHeadingStyle, + ) + } + } +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/sleep/SleepBar.kt b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/SleepBar.kt new file mode 100644 index 0000000000..726a3112f5 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/SleepBar.kt @@ -0,0 +1,362 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.sleep + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.example.jetlagged.data.sleepData +import com.example.jetlagged.ui.theme.JetLaggedTheme +import com.example.jetlagged.ui.theme.LegendHeadingStyle + +@Composable +fun SleepBar(sleepData: SleepDayData, modifier: Modifier = Modifier) { + var isExpanded by rememberSaveable { + mutableStateOf(false) + } + + val transition = updateTransition(targetState = isExpanded, label = "expanded") + + Column( + modifier = modifier + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + isExpanded = !isExpanded + }, + ) { + SleepRoundedBar( + sleepData, + transition, + ) + + transition.AnimatedVisibility( + enter = fadeIn(animationSpec = tween(animationDuration)) + expandVertically( + animationSpec = tween(animationDuration), + ), + exit = fadeOut(animationSpec = tween(animationDuration)) + shrinkVertically( + animationSpec = tween(animationDuration), + ), + content = { + DetailLegend() + }, + visible = { it }, + ) + } +} + +@Composable +private fun SleepRoundedBar(sleepData: SleepDayData, transition: Transition) { + val textMeasurer = rememberTextMeasurer() + + val height by transition.animateDp(label = "height", transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = + Spring.StiffnessLow, + ) + }) { targetExpanded -> + if (targetExpanded) 100.dp else 24.dp + } + val animationProgress by transition.animateFloat(label = "progress", transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = + Spring.StiffnessLow, + ) + }) { target -> + if (target) 1f else 0f + } + + val sleepGradientBarColorStops = sleepGradientBarColorStops() + Spacer( + modifier = Modifier + .drawWithCache { + val width = this.size.width + val cornerRadiusStartPx = 2.dp.toPx() + val collapsedCornerRadiusPx = 10.dp.toPx() + val animatedCornerRadius = CornerRadius( + lerp(cornerRadiusStartPx, collapsedCornerRadiusPx, (1 - animationProgress)), + ) + + val lineThicknessPx = lineThickness.toPx() + val roundedRectPath = Path() + roundedRectPath.addRoundRect( + RoundRect( + rect = Rect( + Offset(x = 0f, y = -lineThicknessPx / 2f), + Size( + this.size.width + lineThicknessPx * 2, + this.size.height + lineThicknessPx, + ), + ), + cornerRadius = animatedCornerRadius, + ), + ) + val roundedCornerStroke = Stroke( + lineThicknessPx, + cap = StrokeCap.Round, + join = StrokeJoin.Round, + pathEffect = PathEffect.cornerPathEffect( + cornerRadiusStartPx * animationProgress, + ), + ) + val barHeightPx = barHeight.toPx() + + val sleepGraphPath = generateSleepPath( + this.size, + sleepData, width, barHeightPx, animationProgress, + lineThickness.toPx() / 2f, + ) + val gradientBrush = + Brush.verticalGradient( + colorStops = sleepGradientBarColorStops.toTypedArray(), + startY = 0f, + endY = SleepType.entries.size * barHeightPx, + ) + val textResult = textMeasurer.measure(AnnotatedString(sleepData.sleepScoreEmoji)) + + onDrawBehind { + drawSleepBar( + roundedRectPath, + sleepGraphPath, + gradientBrush, + roundedCornerStroke, + animationProgress, + textResult, + cornerRadiusStartPx, + ) + } + } + .height(height) + .fillMaxWidth(), + ) +} + +private fun DrawScope.drawSleepBar( + roundedRectPath: Path, + sleepGraphPath: Path, + gradientBrush: Brush, + roundedCornerStroke: Stroke, + animationProgress: Float, + textResult: TextLayoutResult, + cornerRadiusStartPx: Float, +) { + clipPath(roundedRectPath) { + drawPath(sleepGraphPath, brush = gradientBrush) + drawPath( + sleepGraphPath, + style = roundedCornerStroke, + brush = gradientBrush, + ) + } + + translate(left = -animationProgress * (textResult.size.width + textPadding.toPx())) { + drawText( + textResult, + topLeft = Offset(textPadding.toPx(), cornerRadiusStartPx), + ) + } +} + +/** + * Generate the path for the different sleep periods. + */ +private fun generateSleepPath( + canvasSize: Size, + sleepData: SleepDayData, + width: Float, + barHeightPx: Float, + heightAnimation: Float, + lineThicknessPx: Float, +): Path { + val path = Path() + + var previousPeriod: SleepPeriod? = null + + path.moveTo(0f, 0f) + + sleepData.sleepPeriods.forEach { period -> + val percentageOfTotal = sleepData.fractionOfTotalTime(period) + val periodWidth = percentageOfTotal * width + val startOffsetPercentage = sleepData.minutesAfterSleepStart(period) / + sleepData.totalTimeInBed.toMinutes().toFloat() + val halfBarHeight = canvasSize.height / SleepType.entries.size / 2f + + val offset = if (previousPeriod == null) { + 0f + } else { + halfBarHeight + } + + val offsetY = lerp( + 0f, + period.type.heightSleepType() * canvasSize.height, heightAnimation, + ) + // step 1 - draw a line from previous sleep period to current + if (previousPeriod != null) { + path.lineTo( + x = startOffsetPercentage * width + lineThicknessPx, + y = offsetY + offset, + ) + } + + // step 2 - add the current sleep period as rectangle to path + path.addRect( + rect = Rect( + offset = Offset(x = startOffsetPercentage * width + lineThicknessPx, y = offsetY), + size = canvasSize.copy(width = periodWidth, height = barHeightPx), + ), + ) + // step 3 - move to the middle of the current sleep period + path.moveTo( + x = startOffsetPercentage * width + periodWidth + lineThicknessPx, + y = offsetY + halfBarHeight, + ) + + previousPeriod = period + } + return path +} + +@Preview +@Composable +private fun DetailLegend() { + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SleepType.entries.forEach { + LegendItem(it) + } + } +} + +@Composable +private fun LegendItem(sleepType: SleepType) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(colorForSleepType(sleepType)), + ) + Text( + stringResource(id = sleepType.title), + style = LegendHeadingStyle, + modifier = Modifier.padding(start = 4.dp), + ) + } +} + +@Preview +@Composable +fun SleepBarPreview() { + SleepBar(sleepData = sleepData.sleepDayData.first()) +} + +private val lineThickness = 2.dp +private val barHeight = 24.dp +private const val animationDuration = 500 +private val textPadding = 4.dp + +@Composable +fun sleepGradientBarColorStops(): List> = SleepType.entries.map { + Pair( + when (it) { + SleepType.Awake -> 0f + SleepType.REM -> 0.33f + SleepType.Light -> 0.66f + SleepType.Deep -> 1f + }, + colorForSleepType(it), + ) +} + +private fun SleepType.heightSleepType(): Float { + return when (this) { + SleepType.Awake -> 0f + SleepType.REM -> 0.25f + SleepType.Light -> 0.5f + SleepType.Deep -> 0.75f + } +} + +@Composable +fun colorForSleepType(sleepType: SleepType): Color = when (sleepType) { + SleepType.Awake -> JetLaggedTheme.extraColors.sleepAwake + SleepType.REM -> JetLaggedTheme.extraColors.sleepRem + SleepType.Light -> JetLaggedTheme.extraColors.sleepLight + SleepType.Deep -> JetLaggedTheme.extraColors.sleepDeep +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/sleep/SleepData.kt b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/SleepData.kt new file mode 100644 index 0000000000..57b95d0c75 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/SleepData.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.sleep + +import com.example.jetlagged.R +import java.time.Duration +import java.time.LocalDateTime + +data class SleepGraphData(val sleepDayData: List) { + val earliestStartHour: Int by lazy { + sleepDayData.minOf { it.firstSleepStart.hour } + } + val latestEndHour: Int by lazy { + sleepDayData.maxOf { it.lastSleepEnd.hour } + } +} + +data class SleepDayData(val startDate: LocalDateTime, val sleepPeriods: List, val sleepScore: Int) { + val firstSleepStart: LocalDateTime by lazy { + sleepPeriods.sortedBy(SleepPeriod::startTime).first().startTime + } + val lastSleepEnd: LocalDateTime by lazy { + sleepPeriods.sortedBy(SleepPeriod::startTime).last().endTime + } + val totalTimeInBed: Duration by lazy { + Duration.between(firstSleepStart, lastSleepEnd) + } + + val sleepScoreEmoji: String by lazy { + when (sleepScore) { + in 0..40 -> "😖" + in 41..60 -> "😏" + in 60..70 -> "😴" + in 71..100 -> "😃" + else -> "🤷‍" + } + } + + fun fractionOfTotalTime(sleepPeriod: SleepPeriod): Float { + return sleepPeriod.duration.toMinutes() / totalTimeInBed.toMinutes().toFloat() + } + + fun minutesAfterSleepStart(sleepPeriod: SleepPeriod): Long { + return Duration.between( + firstSleepStart, + sleepPeriod.startTime, + ).toMinutes() + } +} + +data class SleepPeriod(val startTime: LocalDateTime, val endTime: LocalDateTime, val type: SleepType) { + + val duration: Duration by lazy { + Duration.between(startTime, endTime) + } +} + +enum class SleepType(val title: Int) { + Awake(R.string.sleep_type_awake), + REM(R.string.sleep_type_rem), + Light(R.string.sleep_type_light), + Deep(R.string.sleep_type_deep), +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/sleep/TimeGraph.kt b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/TimeGraph.kt new file mode 100644 index 0000000000..2a45df069c --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/TimeGraph.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.sleep + +import androidx.compose.foundation.layout.LayoutScopeMarker +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.temporal.ChronoUnit +import kotlin.math.roundToInt + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun TimeGraph( + hoursHeader: @Composable () -> Unit, + dayItemsCount: Int, + dayLabel: @Composable (index: Int) -> Unit, + bar: @Composable TimeGraphScope.(index: Int) -> Unit, + modifier: Modifier = Modifier, +) { + val dayLabels = @Composable { repeat(dayItemsCount) { dayLabel(it) } } + val bars = @Composable { repeat(dayItemsCount) { TimeGraphScope.bar(it) } } + Layout( + contents = listOf(hoursHeader, dayLabels, bars), + modifier = modifier.padding(bottom = 32.dp), + ) { + (hoursHeaderMeasurables, dayLabelMeasurables, barMeasureables), + constraints, + -> + require(hoursHeaderMeasurables.size == 1) { + "hoursHeader should only emit one composable" + } + val hoursHeaderPlaceable = hoursHeaderMeasurables.first().measure(constraints) + + val dayLabelPlaceables = dayLabelMeasurables.map { measurable -> + val placeable = measurable.measure(constraints) + placeable + } + + var totalHeight = hoursHeaderPlaceable.height + + val barPlaceables = barMeasureables.map { measurable -> + val barParentData = measurable.parentData as TimeGraphParentData + val barWidth = (barParentData.duration * hoursHeaderPlaceable.width).roundToInt() + + val barPlaceable = measurable.measure( + constraints.copy( + minWidth = barWidth, + maxWidth = barWidth, + ), + ) + totalHeight += barPlaceable.height + barPlaceable + } + + val totalWidth = dayLabelPlaceables.first().width + hoursHeaderPlaceable.width + + layout(totalWidth, totalHeight) { + val xPosition = dayLabelPlaceables.first().width + var yPosition = hoursHeaderPlaceable.height + + hoursHeaderPlaceable.place(xPosition, 0) + + barPlaceables.forEachIndexed { index, barPlaceable -> + val barParentData = barPlaceable.parentData as TimeGraphParentData + val barOffset = (barParentData.offset * hoursHeaderPlaceable.width).roundToInt() + + barPlaceable.place(xPosition + barOffset, yPosition) + // the label depend on the size of the bar content - so should use the same y + val dayLabelPlaceable = dayLabelPlaceables[index] + dayLabelPlaceable.place(x = 0, y = yPosition) + + yPosition += barPlaceable.height + } + } + } +} + +@LayoutScopeMarker +@Immutable +object TimeGraphScope { + @Stable + fun Modifier.timeGraphBar(start: LocalDateTime, end: LocalDateTime, hours: List): Modifier { + val earliestTime = LocalTime.of(hours.first(), 0) + val durationInHours = ChronoUnit.MINUTES.between(start, end) / 60f + val durationFromEarliestToStartInHours = + ChronoUnit.MINUTES.between(earliestTime, start.toLocalTime()) / 60f + // we add extra half of an hour as hour label text is visually centered in its slot + val offsetInHours = durationFromEarliestToStartInHours + 0.5f + return then( + TimeGraphParentData( + duration = durationInHours / hours.size, + offset = offsetInHours / hours.size, + ), + ) + } +} + +class TimeGraphParentData(val duration: Float, val offset: Float) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?) = this@TimeGraphParentData +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Color.kt b/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Color.kt new file mode 100644 index 0000000000..dfb07cb39e --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Color.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.ui.theme + +import androidx.compose.ui.graphics.Color + +val White = Color(0xFFFFFFFF) +val Black = Color(0xFF000000) +val Lilac = Color(0xFFCCB6DC) +val DarkLilac = Color(0xFF715386) +val Yellow = Color(0xFFFFCB66) +val Red = Color(0xFFB40000) +val YellowVariant = Color(0xFFFFDE9F) +val RedVariant = Color(0xFFF30D0D) +val Coral = Color(0xFFF3A397) +val DarkCoral = Color(0xFF8F554C) +val MintGreen = Color(0xFFACD6B8) +val DarkMintGreen = Color(0xFF537C5E) +val LightBlue = Color(0xFFBBDEFB) +val DarkBlue = Color(0xFF56738B) + +val SleepAwake = Color(0xFFFFEAC1) +val SleepAwakeDark = Color(0xFFEB3F00) +val SleepRem = Color(0xFFFFDD9A) +val SleepRemDark = Color(0xFFFF8248) +val SleepLight = Color(0xFFFFCB66) +val SleepLightDark = Color(0xFFFD4D4D) +val SleepDeep = Color(0xFFFF973C) +val SleepDeepDark = Color(0xFFB40003) + +val Pink = Color(0xFFEAA8A9) +val DarkPink = Color(0xFF93595A) +val Purple = Color(0xFFD2B4D3) +val DarkPurple = Color(0xFF8B6095) +val Green = Color(0xFFADD7B9) +val DarkGreen = Color(0xFF538D64) diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Theme.kt b/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Theme.kt new file mode 100644 index 0000000000..4d6f0b34bb --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Theme.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +private val LightColorScheme = lightColorScheme( + primary = Yellow, + secondary = MintGreen, + tertiary = Coral, + secondaryContainer = Yellow, + surface = White, +) +private val DarkColorScheme = darkColorScheme( + primary = Red, + secondary = DarkMintGreen, + tertiary = DarkCoral, + secondaryContainer = Red, + surface = Black, +) + +data class JetLaggedExtraColors( + val header: Color = Color.Unspecified, + val cardBackground: Color = Color.Unspecified, + val bed: Color = Color.Unspecified, + val sleep: Color = Color.Unspecified, + val wellness: Color = Color.Unspecified, + val heart: Color = Color.Unspecified, + val heartWave: List = listOf(Color.Unspecified), + val heartWaveBackground: Color = Color.Unspecified, + val sleepChartPrimary: Color = Color.Unspecified, + val sleepChartSecondary: Color = Color.Unspecified, + val sleepAwake: Color = Color.Unspecified, + val sleepRem: Color = Color.Unspecified, + val sleepLight: Color = Color.Unspecified, + val sleepDeep: Color = Color.Unspecified, +) +val LocalExtraColors = staticCompositionLocalOf { + JetLaggedExtraColors() +} +private val LightExtraColors = JetLaggedExtraColors( + header = Yellow, + cardBackground = White, + bed = Lilac, + sleep = MintGreen, + wellness = LightBlue, + heart = Coral, + heartWave = listOf(Pink, Purple, Green), + heartWaveBackground = Coral.copy(alpha = 0.2f), + sleepChartPrimary = Yellow, + sleepChartSecondary = YellowVariant, + sleepAwake = SleepAwake, + sleepRem = SleepRem, + sleepLight = SleepLight, + sleepDeep = SleepDeep, +) +private val DarkExtraColors = JetLaggedExtraColors( + header = Red, + cardBackground = Black, + bed = DarkLilac, + sleep = DarkMintGreen, + wellness = DarkBlue, + heart = DarkCoral, + heartWave = listOf(DarkPink, DarkPurple, DarkGreen), + heartWaveBackground = DarkCoral.copy(alpha = 0.4f), + sleepChartPrimary = Red, + sleepChartSecondary = RedVariant, + sleepAwake = SleepAwakeDark, + sleepRem = SleepRemDark, + sleepLight = SleepLightDark, + sleepDeep = SleepDeepDark, +) + +private val shapes: Shapes + @Composable + get() = MaterialTheme.shapes.copy( + large = CircleShape, + ) +@Composable +fun JetLaggedTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val colorScheme: ColorScheme + val extraColors: JetLaggedExtraColors + if (isDarkTheme) { + colorScheme = DarkColorScheme + extraColors = DarkExtraColors + } else { + colorScheme = LightColorScheme + extraColors = LightExtraColors + } + + CompositionLocalProvider(LocalExtraColors provides extraColors) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + shapes = shapes, + content = content, + ) + } +} + +object JetLaggedTheme { + val extraColors: JetLaggedExtraColors + @Composable + get() = LocalExtraColors.current +} diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Type.kt b/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Type.kt new file mode 100644 index 0000000000..b76e663b45 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Type.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalTextApi::class) + +package com.example.jetlagged.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont +import androidx.compose.ui.unit.sp +import com.example.jetlagged.R + +val fontName = GoogleFont("Lato") + +val provider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs, +) +val fontFamily = FontFamily( + Font(googleFont = fontName, fontProvider = provider), +) +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), +) + +val TitleBarStyle = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight(700), + letterSpacing = 0.5.sp, + fontFamily = fontFamily, +) + +val HeadingStyle = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight(600), + letterSpacing = 0.5.sp, + fontFamily = fontFamily, +) + +val SmallHeadingStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight(600), + letterSpacing = 0.5.sp, + fontFamily = fontFamily, +) + +val LegendHeadingStyle = TextStyle( + fontSize = 10.sp, + fontWeight = FontWeight(600), + letterSpacing = 0.5.sp, + fontFamily = fontFamily, +) + +val TitleStyle = TextStyle( + fontSize = 36.sp, + fontWeight = FontWeight(500), + letterSpacing = 0.5.sp, + fontFamily = fontFamily, +) diff --git a/JetLagged/app/src/main/java/com/example/jetlagged/ui/util/MultiDevicePreview.kt b/JetLagged/app/src/main/java/com/example/jetlagged/ui/util/MultiDevicePreview.kt new file mode 100644 index 0000000000..8f908e3238 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/ui/util/MultiDevicePreview.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetlagged.ui.util + +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview + +@Preview( + name = "small font", + group = "font scales", + fontScale = 0.5f, +) +@Preview( + name = "large font", + group = "font scales", + fontScale = 1.5f, +) +annotation class FontScalePreviews + +@Preview(showBackground = true) +@Preview(device = Devices.TABLET, showBackground = true) +@Preview(device = Devices.FOLDABLE, showBackground = true) +@Preview(device = Devices.PIXEL_2) +annotation class MultiDevicePreview diff --git a/JetLagged/app/src/main/res/drawable/ic_bedtime.xml b/JetLagged/app/src/main/res/drawable/ic_bedtime.xml new file mode 100644 index 0000000000..b422106822 --- /dev/null +++ b/JetLagged/app/src/main/res/drawable/ic_bedtime.xml @@ -0,0 +1,9 @@ + + + diff --git a/JetLagged/app/src/main/res/drawable/ic_home.xml b/JetLagged/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000000..9ef27eace1 --- /dev/null +++ b/JetLagged/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/JetLagged/app/src/main/res/drawable/ic_launcher_foreground.xml b/JetLagged/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..4b6e045ddf --- /dev/null +++ b/JetLagged/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/JetLagged/app/src/main/res/drawable/ic_leaderboard.xml b/JetLagged/app/src/main/res/drawable/ic_leaderboard.xml new file mode 100644 index 0000000000..08948815b3 --- /dev/null +++ b/JetLagged/app/src/main/res/drawable/ic_leaderboard.xml @@ -0,0 +1,9 @@ + + + diff --git a/JetLagged/app/src/main/res/drawable/ic_menu.xml b/JetLagged/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000000..7915d80e52 --- /dev/null +++ b/JetLagged/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/JetLagged/app/src/main/res/drawable/ic_settings.xml b/JetLagged/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000000..0b0cc8700e --- /dev/null +++ b/JetLagged/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/JetLagged/app/src/main/res/drawable/ic_single_bed.xml b/JetLagged/app/src/main/res/drawable/ic_single_bed.xml new file mode 100644 index 0000000000..f1ac9fddee --- /dev/null +++ b/JetLagged/app/src/main/res/drawable/ic_single_bed.xml @@ -0,0 +1,9 @@ + + + diff --git a/JetLagged/app/src/main/res/drawable/ic_watch.xml b/JetLagged/app/src/main/res/drawable/ic_watch.xml new file mode 100644 index 0000000000..e0b2046cec --- /dev/null +++ b/JetLagged/app/src/main/res/drawable/ic_watch.xml @@ -0,0 +1,9 @@ + + + diff --git a/JetLagged/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/JetLagged/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..5c84730caa --- /dev/null +++ b/JetLagged/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/JetLagged/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/JetLagged/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..5c84730caa --- /dev/null +++ b/JetLagged/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/JetLagged/app/src/main/res/mipmap-hdpi/ic_launcher.png b/JetLagged/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..a5308b5f4e Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/JetLagged/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/JetLagged/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..0b31f61280 Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/JetLagged/app/src/main/res/mipmap-mdpi/ic_launcher.png b/JetLagged/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..3b2deb98e8 Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/JetLagged/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/JetLagged/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..898578a7af Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/JetLagged/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/JetLagged/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..f4d7463dcc Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/JetLagged/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/JetLagged/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..b539df7f5d Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/JetLagged/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/JetLagged/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..35af1f066c Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/JetLagged/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/JetLagged/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..dbd9c1ee26 Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/JetLagged/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/JetLagged/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..f207407608 Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/JetLagged/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/JetLagged/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..ea9c0666d2 Binary files /dev/null and b/JetLagged/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/JetLagged/app/src/main/res/values-v23/font_certs.xml b/JetLagged/app/src/main/res/values-v23/font_certs.xml new file mode 100644 index 0000000000..1c77c22269 --- /dev/null +++ b/JetLagged/app/src/main/res/values-v23/font_certs.xml @@ -0,0 +1,29 @@ + + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + \ No newline at end of file diff --git a/JetLagged/app/src/main/res/values/ic_launcher_background.xml b/JetLagged/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..8e95cccd8d --- /dev/null +++ b/JetLagged/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFC161 + \ No newline at end of file diff --git a/JetLagged/app/src/main/res/values/strings.xml b/JetLagged/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..38a830e7f5 --- /dev/null +++ b/JetLagged/app/src/main/res/values/strings.xml @@ -0,0 +1,44 @@ + + + + JetLagged + Back + JetLagged + AVG. TIME IN BED + AVG. SLEEP TIME + Not implemented yet + 8h2min + 7h15min + Awake + REM + Light + Deep + Day + Week + Month + 6M + 1Y + Heart Rate + Wellness + Snoring + Coughing + Respiration + AVE TIME SLEEP + AVE TIME IN BED + Ambiance + Room Temperature + + + diff --git a/JetLagged/app/src/main/res/values/themes.xml b/JetLagged/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..b991a0f935 --- /dev/null +++ b/JetLagged/app/src/main/res/values/themes.xml @@ -0,0 +1,17 @@ + + + + - - diff --git a/JetNews/app/src/main/res/values/themes.xml b/JetNews/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..2a88e87472 --- /dev/null +++ b/JetNews/app/src/main/res/values/themes.xml @@ -0,0 +1,18 @@ + + + - - diff --git a/Jetcaster/build.gradle b/Jetcaster/build.gradle deleted file mode 100644 index 702bd0718c..0000000000 --- a/Jetcaster/build.gradle +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.jetcaster.buildsrc.Libs -import com.example.jetcaster.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.10.0' -} - -subprojects { - repositories { - google() - mavenCentral() - jcenter() - - // Jetpack Compose SNAPSHOTs - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { url "https://androidx.dev/snapshots/builds/${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" } - maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - - ktlint(Versions.ktlint) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - - // Enable experimental coroutines APIs, including Flow - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' - freeCompilerArgs += '-Xopt-in=kotlin.Experimental' - - // Set JVM target to 1.8 - jvmTarget = "1.8" - } - } -} diff --git a/Jetcaster/build.gradle.kts b/Jetcaster/build.gradle.kts new file mode 100644 index 0000000000..dc80456ea5 --- /dev/null +++ b/Jetcaster/build.gradle.kts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.gradle.versions) + alias(libs.plugins.version.catalog.update) + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.compose) apply false + alias(libs.plugins.spotless) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure { + kotlin { + target("**/*.kt") + targetExclude("${layout.buildDirectory}/**/*.kt") + ktlint() + licenseHeaderFile(rootProject.file("spotless/copyright.kt")) + } + kotlinGradle { + target("*.gradle.kts") + targetExclude("${layout.buildDirectory}/**/*.kt") + ktlint() + // Look for the first line that doesn't have a block comment (assumed to be the license) + licenseHeaderFile(rootProject.file("spotless/copyright.kt"), "(^(?![\\/ ]\\*).*$)") + } + } +} diff --git a/Jetcaster/buildSrc/build.gradle.kts b/Jetcaster/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Jetcaster/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Jetcaster/buildSrc/src/main/java/com/example/jetcaster/buildsrc/dependencies.kt b/Jetcaster/buildSrc/src/main/java/com/example/jetcaster/buildsrc/dependencies.kt deleted file mode 100644 index e53ec228db..0000000000 --- a/Jetcaster/buildSrc/src/main/java/com/example/jetcaster/buildsrc/dependencies.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.buildsrc - -object Versions { - const val ktlint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha11" - const val jdkDesugar = "com.android.tools:desugar_jdk_libs:1.0.9" - - const val material = "com.google.android.material:material:1.1.0" - - object Accompanist { - private const val version = "0.7.0" - const val coil = "com.google.accompanist:accompanist-coil:$version" - const val insets = "com.google.accompanist:accompanist-insets:$version" - const val pager = "com.google.accompanist:accompanist-pager:$version" - } - - object Kotlin { - private const val version = "1.4.31" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.2" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object OkHttp { - private const val version = "4.9.0" - const val okhttp = "com.squareup.okhttp3:okhttp:$version" - const val logging = "com.squareup.okhttp3:logging-interceptor:$version" - } - - object JUnit { - private const val version = "4.13" - const val junit = "junit:junit:$version" - } - - object AndroidX { - const val appcompat = "androidx.appcompat:appcompat:1.2.0" - const val palette = "androidx.palette:palette:1.0.0" - - const val coreKtx = "androidx.core:core-ktx:1.5.0-beta03" - - object Activity { - const val activityCompose = "androidx.activity:activity-compose:1.3.0-alpha05" - } - - object Constraint { - const val constraintLayoutCompose = "androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha03" - } - - object Compose { - private const val snapshot = "" - private const val version = "1.0.0-beta03" - - @get:JvmStatic - val snapshotUrl: String - get() = "https://androidx.dev/snapshots/builds/$snapshot/artifacts/repository/" - - const val runtime = "androidx.compose.runtime:runtime:$version" - const val foundation = "androidx.compose.foundation:foundation:${version}" - const val layout = "androidx.compose.foundation:foundation-layout:${version}" - - const val ui = "androidx.compose.ui:ui:${version}" - const val material = "androidx.compose.material:material:${version}" - const val materialIconsExtended = "androidx.compose.material:material-icons-extended:${version}" - - const val tooling = "androidx.compose.ui:ui-tooling:${version}" - } - - object Lifecycle { - private const val version = "2.3.0-beta01" - const val viewModelCompose = "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha03" - const val viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - } - - object Test { - private const val version = "1.2.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - - object Room { - private const val version = "2.2.5" - const val runtime = "androidx.room:room-runtime:${version}" - const val ktx = "androidx.room:room-ktx:${version}" - const val compiler = "androidx.room:room-compiler:${version}" - } - } - - object Rome { - private const val version = "1.14.1" - const val rome = "com.rometools:rome:$version" - const val modules = "com.rometools:rome-modules:$version" - } -} diff --git a/Jetcaster/buildscripts/toml-updater-config.gradle b/Jetcaster/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/Jetcaster/buildscripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/Rally/app/.gitignore b/Jetcaster/core/.gitignore similarity index 100% rename from Rally/app/.gitignore rename to Jetcaster/core/.gitignore diff --git a/Jetcaster/core/data-testing/.gitignore b/Jetcaster/core/data-testing/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/data-testing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/data-testing/build.gradle.kts b/Jetcaster/core/data-testing/build.gradle.kts new file mode 100644 index 0000000000..116f95aa6c --- /dev/null +++ b/Jetcaster/core/data-testing/build.gradle.kts @@ -0,0 +1,67 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.example.jetcaster.core.data.testing" + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + + defaultConfig { + minSdk = + libs.versions.minSdk + .get() + .toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } +} +dependencies { + implementation(libs.androidx.core.ktx) + implementation(projects.core.data) + coreLibraryDesugaring(libs.core.jdk.desugaring) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/Jetcaster/core/data-testing/consumer-rules.pro b/Jetcaster/core/data-testing/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/data-testing/proguard-rules.pro b/Jetcaster/core/data-testing/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/data-testing/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/data-testing/src/main/AndroidManifest.xml b/Jetcaster/core/data-testing/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/data-testing/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestCategoryStore.kt b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestCategoryStore.kt new file mode 100644 index 0000000000..8f8ac64dba --- /dev/null +++ b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestCategoryStore.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.testing.repository + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.repository.CategoryStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** + * A [CategoryStore] used for testing. + * + * // TODO: Move to :testing module upon merging PR #1379 + */ +class TestCategoryStore : CategoryStore { + + private val categoryFlow = MutableStateFlow>(emptyList()) + private val podcastsInCategoryFlow = + MutableStateFlow>>(emptyMap()) + private val episodesFromPodcasts = + MutableStateFlow>>(emptyMap()) + + override fun categoriesSortedByPodcastCount(limit: Int): Flow> = categoryFlow + + override fun podcastsInCategorySortedByPodcastCount(categoryId: Long, limit: Int): Flow> = + podcastsInCategoryFlow.map { + it[categoryId]?.take(limit) ?: emptyList() + } + + override fun episodesFromPodcastsInCategory(categoryId: Long, limit: Int): Flow> = episodesFromPodcasts.map { + it[categoryId]?.take(limit) ?: emptyList() + } + + override suspend fun addCategory(category: Category): Long = -1 + + override suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) {} + + override fun getCategory(name: String): Flow = flowOf() + + /** + * Test-only API for setting the list of categories backed by this [TestCategoryStore]. + */ + fun setCategories(categories: List) { + categoryFlow.value = categories + } + + /** + * Test-only API for setting the list of podcasts in a category backed by this + * [TestCategoryStore]. + */ + fun setPodcastsInCategory(categoryId: Long, podcastsInCategory: List) { + podcastsInCategoryFlow.update { + it + Pair(categoryId, podcastsInCategory) + } + } + + /** + * Test-only API for setting the list of podcasts in a category backed by this + * [TestCategoryStore]. + */ + fun setEpisodesFromPodcast(categoryId: Long, podcastsInCategory: List) { + episodesFromPodcasts.update { + it + Pair(categoryId, podcastsInCategory) + } + } +} diff --git a/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestEpisodeStore.kt b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestEpisodeStore.kt new file mode 100644 index 0000000000..2afb76aa0f --- /dev/null +++ b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestEpisodeStore.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.testing.repository + +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.repository.EpisodeStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +// TODO: Move to :testing module upon merging PR #1379 +class TestEpisodeStore : EpisodeStore { + + private val episodesFlow = MutableStateFlow>(listOf()) + override fun episodeWithUri(episodeUri: String): Flow = episodesFlow.map { episodes -> + episodes.first { it.uri == episodeUri } + } + + override fun episodeAndPodcastWithUri(episodeUri: String): Flow = episodesFlow.map { episodes -> + val e = episodes.first { + it.uri == episodeUri + } + EpisodeToPodcast().apply { + episode = e + _podcasts = emptyList() + } + } + + override fun episodesInPodcast(podcastUri: String, limit: Int): Flow> = episodesFlow.map { episodes -> + episodes.filter { + it.podcastUri == podcastUri + }.map { e -> + EpisodeToPodcast().apply { + episode = e + } + } + } + + override fun episodesInPodcasts(podcastUris: List, limit: Int): Flow> = episodesFlow.map { episodes -> + episodes.filter { + podcastUris.contains(it.podcastUri) + }.map { ep -> + EpisodeToPodcast().apply { + episode = ep + } + } + } + + override suspend fun addEpisodes(episodes: Collection) = episodesFlow.update { + it + episodes + } + + override suspend fun deleteEpisode(episode: Episode) = episodesFlow.update { + it - episode + } + + override suspend fun isEmpty(): Boolean = episodesFlow.first().isEmpty() +} diff --git a/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestPodcastStore.kt b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestPodcastStore.kt new file mode 100644 index 0000000000..24e6916325 --- /dev/null +++ b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestPodcastStore.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.testing.repository + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.repository.PodcastStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +// TODO: Move to :testing module upon merging PR #1379 +class TestPodcastStore : PodcastStore { + + private val podcastFlow = MutableStateFlow>(listOf()) + private val followedPodcasts = mutableSetOf() + override fun podcastWithUri(uri: String): Flow = podcastFlow.map { podcasts -> + podcasts.first { it.uri == uri } + } + + override fun podcastWithExtraInfo(podcastUri: String): Flow = podcastFlow.map { podcasts -> + val podcast = podcasts.first { it.uri == podcastUri } + PodcastWithExtraInfo().apply { + this.podcast = podcast + } + } + + override fun podcastsSortedByLastEpisode(limit: Int): Flow> = podcastFlow.map { podcasts -> + podcasts.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = followedPodcasts.contains(p.uri) + } + } + } + + override fun followedPodcastsSortedByLastEpisode(limit: Int): Flow> = podcastFlow.map { podcasts -> + podcasts.filter { + followedPodcasts.contains(it.uri) + }.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = true + } + } + } + + override fun searchPodcastByTitle(keyword: String, limit: Int): Flow> = podcastFlow.map { podcastList -> + podcastList.filter { + it.title.contains(keyword) + }.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = true + } + } + } + + override fun searchPodcastByTitleAndCategories( + keyword: String, + categories: List, + limit: Int, + ): Flow> = podcastFlow.map { podcastList -> + podcastList.filter { + it.title.contains(keyword) + }.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = true + } + } + } + + override suspend fun togglePodcastFollowed(podcastUri: String) { + if (podcastUri in followedPodcasts) { + unfollowPodcast(podcastUri) + } else { + followPodcast(podcastUri) + } + } + + override suspend fun followPodcast(podcastUri: String) { + followedPodcasts.add(podcastUri) + } + + override suspend fun unfollowPodcast(podcastUri: String) { + followedPodcasts.remove(podcastUri) + } + + override suspend fun addPodcast(podcast: Podcast) = podcastFlow.update { it + podcast } + + override suspend fun isEmpty(): Boolean = podcastFlow.first().isEmpty() +} diff --git a/Jetcaster/core/data/.gitignore b/Jetcaster/core/data/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/data/build.gradle.kts b/Jetcaster/core/data/build.gradle.kts new file mode 100644 index 0000000000..71ee1ea121 --- /dev/null +++ b/Jetcaster/core/data/build.gradle.kts @@ -0,0 +1,98 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.example.jetcaster.core.data" + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + + defaultConfig { + minSdk = + libs.versions.minSdk + .get() + .toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildFeatures { + buildConfig = true + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } +} +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.compose.runtime) + + // Image loading + implementation(libs.coil.kt.compose) + + // Compose + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Networking + implementation(libs.okhttp3) + implementation(libs.okhttp.logging) + + // Database + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + implementation(libs.rometools.rome) + implementation(libs.rometools.modules) + + coreLibraryDesugaring(libs.core.jdk.desugaring) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/Jetcaster/core/data/consumer-rules.pro b/Jetcaster/core/data/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/data/proguard-rules.pro b/Jetcaster/core/data/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/data/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/data/src/main/AndroidManifest.xml b/Jetcaster/core/data/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/data/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt new file mode 100644 index 0000000000..a57199979c --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class Dispatcher(val jetcasterDispatcher: JetcasterDispatchers) + +enum class JetcasterDispatchers { + Main, + IO, +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt similarity index 97% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt index 4b4fb5d0a9..0199678c4c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database import androidx.room.TypeConverter import java.time.Duration diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt new file mode 100644 index 0000000000..b1804d5276 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.example.jetcaster.core.data.database.dao.CategoriesDao +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.dao.TransactionRunnerDao +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry +import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry + +/** + * The [RoomDatabase] we use in this app. + */ +@Database( + entities = [ + Podcast::class, + Episode::class, + PodcastCategoryEntry::class, + Category::class, + PodcastFollowedEntry::class, + ], + version = 1, + exportSchema = false, +) +@TypeConverters(DateTimeTypeConverters::class) +abstract class JetcasterDatabase : RoomDatabase() { + abstract fun podcastsDao(): PodcastsDao + abstract fun episodesDao(): EpisodesDao + abstract fun categoriesDao(): CategoriesDao + abstract fun podcastCategoryEntryDao(): PodcastCategoryEntryDao + abstract fun transactionRunnerDao(): TransactionRunnerDao + abstract fun podcastFollowedEntryDao(): PodcastFollowedEntryDao +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt new file mode 100644 index 0000000000..eca987c370 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Update + +/** + * Base DAO. + */ +interface BaseDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: T): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(vararg entity: T) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: Collection) + + @Update(onConflict = OnConflictStrategy.REPLACE) + suspend fun update(entity: T) + + @Delete + suspend fun delete(entity: T): Int +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt new file mode 100644 index 0000000000..a3b0f7eb3d --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import com.example.jetcaster.core.data.database.model.Category +import kotlinx.coroutines.flow.Flow + +/** + * [Room] DAO for [Category] related operations. + */ +@Dao +abstract class CategoriesDao : BaseDao { + @Query( + """ + SELECT categories.* FROM categories + INNER JOIN ( + SELECT category_id, COUNT(podcast_uri) AS podcast_count FROM podcast_category_entries + GROUP BY category_id + ) ON category_id = categories.id + ORDER BY podcast_count DESC + LIMIT :limit + """, + ) + abstract fun categoriesSortedByPodcastCount(limit: Int): Flow> + + @Query("SELECT * FROM categories WHERE name = :name") + abstract suspend fun getCategoryWithName(name: String): Category? + + @Query("SELECT * FROM categories WHERE name = :name") + abstract fun observeCategory(name: String): Flow +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt new file mode 100644 index 0000000000..c7373840c5 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import kotlinx.coroutines.flow.Flow + +/** + * [Room] DAO for [Episode] related operations. + */ +@Dao +abstract class EpisodesDao : BaseDao { + + @Query( + """ + SELECT * FROM episodes WHERE uri = :uri + """, + ) + abstract fun episode(uri: String): Flow + + @Transaction + @Query( + """ + SELECT episodes.* FROM episodes + INNER JOIN podcasts ON episodes.podcast_uri = podcasts.uri + WHERE episodes.uri = :episodeUri + """, + ) + abstract fun episodeAndPodcast(episodeUri: String): Flow + + @Transaction + @Query( + """ + SELECT * FROM episodes WHERE podcast_uri = :podcastUri + ORDER BY datetime(published) DESC + LIMIT :limit + """, + ) + abstract fun episodesForPodcastUri(podcastUri: String, limit: Int): Flow> + + @Transaction + @Query( + """ + SELECT episodes.* FROM episodes + INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri + WHERE category_id = :categoryId + ORDER BY datetime(published) DESC + LIMIT :limit + """, + ) + abstract fun episodesFromPodcastsInCategory(categoryId: Long, limit: Int): Flow> + + @Query("SELECT COUNT(*) FROM episodes") + abstract suspend fun count(): Int + + @Transaction + @Query( + """ + SELECT * FROM episodes WHERE podcast_uri IN (:podcastUris) + ORDER BY datetime(published) DESC + LIMIT :limit + """, + ) + abstract fun episodesForPodcasts(podcastUris: List, limit: Int): Flow> +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt new file mode 100644 index 0000000000..5291649e34 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry + +/** + * [Room] DAO for [PodcastCategoryEntry] related operations. + */ +@Dao +abstract class PodcastCategoryEntryDao : BaseDao diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt new file mode 100644 index 0000000000..0816cc05e7 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry + +@Dao +abstract class PodcastFollowedEntryDao : BaseDao { + @Query("DELETE FROM podcast_followed_entries WHERE podcast_uri = :podcastUri") + abstract suspend fun deleteWithPodcastUri(podcastUri: String) + + @Query("SELECT COUNT(*) FROM podcast_followed_entries WHERE podcast_uri = :podcastUri") + protected abstract suspend fun podcastFollowRowCount(podcastUri: String): Int + + suspend fun isPodcastFollowed(podcastUri: String): Boolean { + return podcastFollowRowCount(podcastUri) > 0 + } +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt new file mode 100644 index 0000000000..b010938e0a --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import kotlinx.coroutines.flow.Flow + +/** + * [Room] DAO for [Podcast] related operations. + */ +@Dao +abstract class PodcastsDao : BaseDao { + @Query("SELECT * FROM podcasts WHERE uri = :uri") + abstract fun podcastWithUri(uri: String): Flow + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT podcast_uri, MAX(published) AS last_episode_date + FROM episodes + GROUP BY podcast_uri + ) episodes ON podcasts.uri = episodes.podcast_uri + LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = podcasts.uri + WHERE podcasts.uri = :podcastUri + ORDER BY datetime(last_episode_date) DESC + """, + ) + abstract fun podcastWithExtraInfo(podcastUri: String): Flow + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT podcast_uri, MAX(published) AS last_episode_date + FROM episodes + GROUP BY podcast_uri + ) episodes ON podcasts.uri = episodes.podcast_uri + LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """, + ) + abstract fun podcastsSortedByLastEpisode(limit: Int): Flow> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT episodes.podcast_uri, MAX(published) AS last_episode_date + FROM episodes + INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri + WHERE category_id = :categoryId + GROUP BY episodes.podcast_uri + ) inner_query ON podcasts.uri = inner_query.podcast_uri + LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = inner_query.podcast_uri + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """, + ) + abstract fun podcastsInCategorySortedByLastEpisode(categoryId: Long, limit: Int): Flow> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT podcast_uri, MAX(published) AS last_episode_date FROM episodes GROUP BY podcast_uri + ) episodes ON podcasts.uri = episodes.podcast_uri + INNER JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """, + ) + abstract fun followedPodcastsSortedByLastEpisode(limit: Int): Flow> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT podcast_uri, MAX(published) AS last_episode_date FROM episodes GROUP BY podcast_uri + ) episodes ON podcasts.uri = episodes.podcast_uri + INNER JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri + WHERE podcasts.title LIKE '%' || :keyword || '%' + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """, + ) + abstract fun searchPodcastByTitle(keyword: String, limit: Int): Flow> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT episodes.podcast_uri, MAX(published) AS last_episode_date + FROM episodes + INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri + WHERE category_id IN (:categoryIdList) + GROUP BY episodes.podcast_uri + ) inner_query ON podcasts.uri = inner_query.podcast_uri + LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = inner_query.podcast_uri + WHERE podcasts.title LIKE '%' || :keyword || '%' + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """, + ) + abstract fun searchPodcastByTitleAndCategory(keyword: String, categoryIdList: List, limit: Int): Flow> + + @Query("SELECT COUNT(*) FROM podcasts") + abstract suspend fun count(): Int +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt similarity index 95% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt index e7c51cad4f..6f4b0c49e6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database.dao import androidx.room.Dao import androidx.room.Ignore diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt similarity index 86% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt index 3279017b3a..9391e859f8 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo @@ -25,11 +25,11 @@ import androidx.room.PrimaryKey @Entity( tableName = "categories", indices = [ - Index("name", unique = true) - ] + Index("name", unique = true), + ], ) @Immutable data class Category( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, - @ColumnInfo(name = "name") val name: String + @ColumnInfo(name = "name") val name: String, ) diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt new file mode 100644 index 0000000000..e159e5bd20 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import java.time.Duration +import java.time.OffsetDateTime + +@Entity( + tableName = "episodes", + indices = [ + Index("uri", unique = true), + Index("podcast_uri"), + ], + foreignKeys = [ + ForeignKey( + entity = Podcast::class, + parentColumns = ["uri"], + childColumns = ["podcast_uri"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE, + ), + ], +) +@TypeConverters(ListOfStringConverter::class) +data class Episode( + @PrimaryKey @ColumnInfo(name = "uri") val uri: String, + @ColumnInfo(name = "podcast_uri") val podcastUri: String, + @ColumnInfo(name = "title") val title: String, + @ColumnInfo(name = "subtitle") val subtitle: String? = null, + @ColumnInfo(name = "summary") val summary: String? = null, + @ColumnInfo(name = "author") val author: String? = null, + @ColumnInfo(name = "published") val published: OffsetDateTime, + @ColumnInfo(name = "duration") val duration: Duration? = null, + @ColumnInfo(name = "media_urls") val mediaUrls: List, +) + +class ListOfStringConverter { + @TypeConverter + fun fromString(value: String): List { + return value.split(",") + } + + @TypeConverter + fun fromList(list: List): String { + return list.joinToString(",") + } +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt similarity index 92% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt index 4f87ba9e05..8958d23ded 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt @@ -14,13 +14,15 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model +import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Ignore import androidx.room.Relation import java.util.Objects +@Immutable class EpisodeToPodcast { @Embedded lateinit var episode: Episode diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt similarity index 92% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt index 969908f14a..8e8d28f1b5 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo @@ -25,8 +25,8 @@ import androidx.room.PrimaryKey @Entity( tableName = "podcasts", indices = [ - Index("uri", unique = true) - ] + Index("uri", unique = true), + ], ) @Immutable data class Podcast( @@ -35,5 +35,5 @@ data class Podcast( @ColumnInfo(name = "description") val description: String? = null, @ColumnInfo(name = "author") val author: String? = null, @ColumnInfo(name = "image_url") val imageUrl: String? = null, - @ColumnInfo(name = "copyright") val copyright: String? = null + @ColumnInfo(name = "copyright") val copyright: String? = null, ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt similarity index 86% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt index 394af2fca8..1a99493f05 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo @@ -31,25 +31,25 @@ import androidx.room.PrimaryKey parentColumns = ["id"], childColumns = ["category_id"], onUpdate = ForeignKey.CASCADE, - onDelete = ForeignKey.CASCADE + onDelete = ForeignKey.CASCADE, ), ForeignKey( entity = Podcast::class, parentColumns = ["uri"], childColumns = ["podcast_uri"], onUpdate = ForeignKey.CASCADE, - onDelete = ForeignKey.CASCADE - ) + onDelete = ForeignKey.CASCADE, + ), ], indices = [ Index("podcast_uri", "category_id", unique = true), Index("category_id"), - Index("podcast_uri") - ] + Index("podcast_uri"), + ], ) @Immutable data class PodcastCategoryEntry( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "podcast_uri") val podcastUri: String, - @ColumnInfo(name = "category_id") val categoryId: Long + @ColumnInfo(name = "category_id") val categoryId: Long, ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt similarity index 84% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt index 0be51c77bc..7452f09a6a 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo @@ -31,15 +31,15 @@ import androidx.room.PrimaryKey parentColumns = ["uri"], childColumns = ["podcast_uri"], onUpdate = ForeignKey.CASCADE, - onDelete = ForeignKey.CASCADE - ) + onDelete = ForeignKey.CASCADE, + ), ], indices = [ - Index("podcast_uri", unique = true) - ] + Index("podcast_uri", unique = true), + ], ) @Immutable data class PodcastFollowedEntry( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, - @ColumnInfo(name = "podcast_uri") val podcastUri: String + @ColumnInfo(name = "podcast_uri") val podcastUri: String, ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt similarity index 96% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt index 200e6248c2..ea4c02ab54 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.room.ColumnInfo import androidx.room.Embedded @@ -40,11 +40,13 @@ class PodcastWithExtraInfo { override fun equals(other: Any?): Boolean = when { other === this -> true + other is PodcastWithExtraInfo -> { podcast == other.podcast && lastEpisodeDate == other.lastEpisodeDate && isFollowed == other.isFollowed } + else -> false } diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/di/DataDiModule.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/di/DataDiModule.kt new file mode 100644 index 0000000000..2cf54ca720 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/di/DataDiModule.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.di + +import android.content.Context +import androidx.room.Room +import coil.ImageLoader +import com.example.jetcaster.core.data.BuildConfig +import com.example.jetcaster.core.data.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.data.database.JetcasterDatabase +import com.example.jetcaster.core.data.database.dao.CategoriesDao +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.dao.TransactionRunner +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.LocalCategoryStore +import com.example.jetcaster.core.data.repository.LocalEpisodeStore +import com.example.jetcaster.core.data.repository.LocalPodcastStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.rometools.rome.io.SyndFeedInput +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.io.File +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.logging.LoggingEventListener + +@Module +@InstallIn(SingletonComponent::class) +object DataDiModule { + + @Provides + @Singleton + fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient = OkHttpClient.Builder() + .cache(Cache(File(context.cacheDir, "http_cache"), (20 * 1024 * 1024).toLong())) + .apply { + if (BuildConfig.DEBUG) eventListenerFactory(LoggingEventListener.Factory()) + } + .build() + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): JetcasterDatabase = + Room.databaseBuilder(context, JetcasterDatabase::class.java, "data.db") + // This is not recommended for normal apps, but the goal of this sample isn't to + // showcase all of Room. + .fallbackToDestructiveMigration() + .build() + + @Provides + @Singleton + fun provideImageLoader(@ApplicationContext context: Context): ImageLoader = ImageLoader.Builder(context) + // Disable `Cache-Control` header support as some podcast images disable disk caching. + .respectCacheHeaders(false) + .build() + + @Provides + @Singleton + fun provideCategoriesDao(database: JetcasterDatabase): CategoriesDao = database.categoriesDao() + + @Provides + @Singleton + fun providePodcastCategoryEntryDao(database: JetcasterDatabase): PodcastCategoryEntryDao = database.podcastCategoryEntryDao() + + @Provides + @Singleton + fun providePodcastsDao(database: JetcasterDatabase): PodcastsDao = database.podcastsDao() + + @Provides + @Singleton + fun provideEpisodesDao(database: JetcasterDatabase): EpisodesDao = database.episodesDao() + + @Provides + @Singleton + fun providePodcastFollowedEntryDao(database: JetcasterDatabase): PodcastFollowedEntryDao = database.podcastFollowedEntryDao() + + @Provides + @Singleton + fun provideTransactionRunner(database: JetcasterDatabase): TransactionRunner = database.transactionRunnerDao() + + @Provides + @Singleton + fun provideSyndFeedInput() = SyndFeedInput() + + @Provides + @Dispatcher(JetcasterDispatchers.IO) + @Singleton + fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Dispatcher(JetcasterDispatchers.Main) + @Singleton + fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + @Provides + @Singleton + fun provideEpisodeStore(episodeDao: EpisodesDao): EpisodeStore = LocalEpisodeStore(episodeDao) + + @Provides + @Singleton + fun providePodcastStore( + podcastDao: PodcastsDao, + podcastFollowedEntryDao: PodcastFollowedEntryDao, + transactionRunner: TransactionRunner, + ): PodcastStore = LocalPodcastStore( + podcastDao = podcastDao, + podcastFollowedEntryDao = podcastFollowedEntryDao, + transactionRunner = transactionRunner, + ) + + @Provides + @Singleton + fun provideCategoryStore( + categoriesDao: CategoriesDao, + podcastCategoryEntryDao: PodcastCategoryEntryDao, + podcastDao: PodcastsDao, + episodeDao: EpisodesDao, + ): CategoryStore = LocalCategoryStore( + episodesDao = episodeDao, + podcastsDao = podcastDao, + categoriesDao = categoriesDao, + categoryEntryDao = podcastCategoryEntryDao, + ) +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt similarity index 77% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt index 853dab9d82..ff1ef0dc0b 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt @@ -14,19 +14,24 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.network /** * A hand selected list of feeds URLs used for the purposes of displaying real information * in this sample app. */ +private const val NowInAndroid = "https://feeds.libsyn.com/244409/rss" +private const val AndroidDevelopersBackstage = + "https://feeds.feedburner.com/blogspot/AndroidDevelopersBackstage" + val SampleFeeds = listOf( - "https://www.omnycontent.com/d/playlist/aaea4e69-af51-495e-afc9-a9760146922b/dc5b55ca-5f00-4063-b47f-ab870163d2b7/ca63aa52-ef7b-43ee-8ba5-ab8701645231/podcast.rss", + NowInAndroid, + AndroidDevelopersBackstage, + "https://www.omnycontent.com/d/playlist/aaea4e69-af51-495e-afc9-a9760146922b/" + + "dc5b55ca-5f00-4063-b47f-ab870163d2b7/ca63aa52-ef7b-43ee-8ba5-ab8701645231/podcast.rss", "https://audioboom.com/channels/2399216.rss", - "http://nowinandroid.googledevelopers.libsynpro.com/rss", "https://fragmentedpodcast.com/feed/", "https://feeds.megaphone.fm/replyall", - "http://feeds.feedburner.com/blogspot/AndroidDevelopersBackstage", "https://feeds.thisamericanlife.org/talpodcast", "https://feeds.npr.org/510289/podcast.xml", "https://feeds.99percentinvisible.org/99percentinvisible", @@ -38,5 +43,5 @@ val SampleFeeds = listOf( "https://audioboom.com/channels/5025217.rss", "https://feeds.simplecast.com/7PvD7RPL", "https://feeds.buzzsprout.com/1006078.rss", - "https://feeds.megaphone.fm/HSW9992617712" + "https://feeds.megaphone.fm/HSW9992617712", ) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt similarity index 92% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt index a47c27e56a..b0b75933c3 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt @@ -14,19 +14,21 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.network +import java.io.IOException +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback import okhttp3.Response import okhttp3.internal.closeQuietly -import java.io.IOException -import kotlin.coroutines.resumeWithException /** * Suspending wrapper around an OkHttp [Call], using [Call.enqueue]. */ +@OptIn(ExperimentalCoroutinesApi::class) suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> enqueue( object : Callback { @@ -43,7 +45,7 @@ suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation override fun onFailure(call: Call, e: IOException) { continuation.resumeWithException(e) } - } + }, ) continuation.invokeOnCancellation { diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt new file mode 100644 index 0000000000..ea74d99801 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.network + +import coil.network.HttpException +import com.example.jetcaster.core.data.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.Podcast +import com.rometools.modules.itunes.EntryInformation +import com.rometools.modules.itunes.FeedInformation +import com.rometools.rome.feed.synd.SyndEnclosure +import com.rometools.rome.feed.synd.SyndEntry +import com.rometools.rome.feed.synd.SyndFeed +import com.rometools.rome.io.SyndFeedInput +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * A class which fetches some selected podcast RSS feeds. + * + * @param okHttpClient [OkHttpClient] to use for network requests + * @param syndFeedInput [SyndFeedInput] to use for parsing RSS feeds. + * @param ioDispatcher [CoroutineDispatcher] to use for running fetch requests. + */ +class PodcastsFetcher @Inject constructor( + private val okHttpClient: OkHttpClient, + private val syndFeedInput: SyndFeedInput, + @Dispatcher(JetcasterDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, +) { + + /** + * It seems that most podcast hosts do not implement HTTP caching appropriately. + * Instead of fetching data on every app open, we instead allow the use of 'stale' + * network responses (up to 8 hours). + */ + private val cacheControl by lazy { + CacheControl.Builder().maxStale(8, TimeUnit.HOURS).build() + } + + /** + * Returns a [Flow] which fetches each podcast feed and emits it in turn. + * + * The feeds are fetched concurrently, meaning that the resulting emission order may not + * match the order of [feedUrls]. + */ + operator fun invoke(feedUrls: List): Flow { + // We use flatMapMerge here to achieve concurrent fetching/parsing of the feeds. + return feedUrls.asFlow() + .flatMapMerge { feedUrl -> + flow { + emit(fetchPodcast(feedUrl)) + }.catch { e -> + // If an exception was caught while fetching the podcast, wrap it in + // an Error instance. + emit(PodcastRssResponse.Error(e)) + } + } + } + + private suspend fun fetchPodcast(url: String): PodcastRssResponse { + return withContext(ioDispatcher) { + val request = Request.Builder() + .url(url) + .cacheControl(cacheControl) + .build() + + val response = okHttpClient.newCall(request).execute() + + // If the network request wasn't successful, throw an exception + if (!response.isSuccessful) throw HttpException(response) + + // Otherwise we can parse the response using a Rome SyndFeedInput, then map it + // to a Podcast instance. We run this on the IO dispatcher since the parser is reading + // from a stream. + response.body!!.use { body -> + syndFeedInput.build(body.charStream()).toPodcastResponse(url) + } + } + } +} + +sealed class PodcastRssResponse { + data class Error(val throwable: Throwable?) : PodcastRssResponse() + + data class Success(val podcast: Podcast, val episodes: List, val categories: Set) : PodcastRssResponse() +} + +/** + * Map a Rome [SyndFeed] instance to our own [Podcast] data class. + */ +private fun SyndFeed.toPodcastResponse(feedUrl: String): PodcastRssResponse { + val podcastUri = uri ?: feedUrl + val episodes = entries.map { it.toEpisode(podcastUri, it.enclosures) } + + val feedInfo = getModule(PodcastModuleDtd) as? FeedInformation + val podcast = Podcast( + uri = podcastUri, + title = title, + description = feedInfo?.summary ?: description, + author = author, + copyright = copyright, + imageUrl = feedInfo?.imageUri?.toString(), + ) + + val categories = feedInfo?.categories + ?.map { Category(name = it.name) } + ?.toSet() ?: emptySet() + + return PodcastRssResponse.Success(podcast, episodes, categories) +} + +/** + * Map a Rome [SyndEntry] instance to our own [Episode] data class. + */ +private fun SyndEntry.toEpisode(podcastUri: String, enclosures: List): Episode { + val entryInformation = getModule(PodcastModuleDtd) as? EntryInformation + return Episode( + uri = uri, + podcastUri = podcastUri, + title = title, + author = author, + summary = entryInformation?.summary ?: description?.value, + subtitle = entryInformation?.subtitle, + published = Instant.ofEpochMilli(publishedDate.time).atOffset(ZoneOffset.UTC), + duration = entryInformation?.duration?.milliseconds?.let { Duration.ofMillis(it) }, + mediaUrls = enclosures.map { it.url }, + ) +} + +/** + * Most feeds use the following DTD to include extra information related to + * their podcast. Info such as images, summaries, duration, categories is sometimes only available + * via this attributes in this DTD. + */ +private const val PodcastModuleDtd = "http://www.itunes.com/dtds/podcast-1.0.dtd" diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt new file mode 100644 index 0000000000..820f61af7c --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.dao.CategoriesDao +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import kotlinx.coroutines.flow.Flow +interface CategoryStore { + /** + * Returns a flow containing a list of categories which is sorted by the number + * of podcasts in each category. + */ + fun categoriesSortedByPodcastCount(limit: Int = Integer.MAX_VALUE): Flow> + + /** + * Returns a flow containing a list of podcasts in the category with the given [categoryId], + * sorted by the their last episode date. + */ + fun podcastsInCategorySortedByPodcastCount(categoryId: Long, limit: Int = Int.MAX_VALUE): Flow> + + /** + * Returns a flow containing a list of episodes from podcasts in the category with the + * given [categoryId], sorted by the their last episode date. + */ + fun episodesFromPodcastsInCategory(categoryId: Long, limit: Int = Integer.MAX_VALUE): Flow> + + /** + * Adds the category to the database if it doesn't already exist. + * + * @return the id of the newly inserted/existing category + */ + suspend fun addCategory(category: Category): Long + + suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) + + /** + * @return gets the category with [name], if it exists, otherwise, null + */ + fun getCategory(name: String): Flow +} + +/** + * A data repository for [Category] instances. + */ +class LocalCategoryStore constructor( + private val categoriesDao: CategoriesDao, + private val categoryEntryDao: PodcastCategoryEntryDao, + private val episodesDao: EpisodesDao, + private val podcastsDao: PodcastsDao, +) : CategoryStore { + /** + * Returns a flow containing a list of categories which is sorted by the number + * of podcasts in each category. + */ + override fun categoriesSortedByPodcastCount(limit: Int): Flow> { + return categoriesDao.categoriesSortedByPodcastCount(limit) + } + + /** + * Returns a flow containing a list of podcasts in the category with the given [categoryId], + * sorted by the their last episode date. + */ + override fun podcastsInCategorySortedByPodcastCount(categoryId: Long, limit: Int): Flow> { + return podcastsDao.podcastsInCategorySortedByLastEpisode(categoryId, limit) + } + + /** + * Returns a flow containing a list of episodes from podcasts in the category with the + * given [categoryId], sorted by the their last episode date. + */ + override fun episodesFromPodcastsInCategory(categoryId: Long, limit: Int): Flow> { + return episodesDao.episodesFromPodcastsInCategory(categoryId, limit) + } + + /** + * Adds the category to the database if it doesn't already exist. + * + * @return the id of the newly inserted/existing category + */ + override suspend fun addCategory(category: Category): Long { + return when (val local = categoriesDao.getCategoryWithName(category.name)) { + null -> categoriesDao.insert(category) + else -> local.id + } + } + + override suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) { + categoryEntryDao.insert( + PodcastCategoryEntry(podcastUri = podcastUri, categoryId = categoryId), + ) + } + + override fun getCategory(name: String): Flow = categoriesDao.observeCategory(name) +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt new file mode 100644 index 0000000000..126c6a5bfe --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import kotlinx.coroutines.flow.Flow + +interface EpisodeStore { + /** + * Returns a flow containing the episode given [episodeUri]. + */ + fun episodeWithUri(episodeUri: String): Flow + + /** + * Returns a flow containing the episode and corresponding podcast given an [episodeUri]. + */ + fun episodeAndPodcastWithUri(episodeUri: String): Flow + + /** + * Returns a flow containing the list of episodes associated with the podcast with the + * given [podcastUri]. + */ + fun episodesInPodcast(podcastUri: String, limit: Int = Integer.MAX_VALUE): Flow> + + /** + * Returns a list of episodes for the given podcast URIs ordering by most recently published + * to least recently published. + */ + fun episodesInPodcasts(podcastUris: List, limit: Int = Integer.MAX_VALUE): Flow> + + /** + * Add a new [Episode] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + suspend fun addEpisodes(episodes: Collection) + + /** + * Deletes an [Episode] from this store. + */ + suspend fun deleteEpisode(episode: Episode) + + suspend fun isEmpty(): Boolean +} + +/** + * A data repository for [Episode] instances. + */ +class LocalEpisodeStore(private val episodesDao: EpisodesDao) : EpisodeStore { + /** + * Returns a flow containing the episode given [episodeUri]. + */ + override fun episodeWithUri(episodeUri: String): Flow { + return episodesDao.episode(episodeUri) + } + + override fun episodeAndPodcastWithUri(episodeUri: String): Flow = episodesDao.episodeAndPodcast(episodeUri) + + /** + * Returns a flow containing the list of episodes associated with the podcast with the + * given [podcastUri]. + */ + override fun episodesInPodcast(podcastUri: String, limit: Int): Flow> { + return episodesDao.episodesForPodcastUri(podcastUri, limit) + } + + /** + * Returns a list of episodes for the given podcast URIs ordering by most recently published + * to least recently published. + */ + override fun episodesInPodcasts(podcastUris: List, limit: Int): Flow> = + episodesDao.episodesForPodcasts(podcastUris, limit) + + /** + * Add a new [Episode] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + override suspend fun addEpisodes(episodes: Collection) = episodesDao.insertAll(episodes) + + /** + * Deletes an [Episode] from this store. + */ + override suspend fun deleteEpisode(episode: Episode) { + episodesDao.delete(episode) + } + + override suspend fun isEmpty(): Boolean = episodesDao.count() == 0 +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt new file mode 100644 index 0000000000..57e4328c6f --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.dao.TransactionRunner +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import kotlinx.coroutines.flow.Flow + +interface PodcastStore { + /** + * Return a flow containing the [Podcast] with the given [uri]. + */ + fun podcastWithUri(uri: String): Flow + + /** + * Return a flow containing the [PodcastWithExtraInfo] with the given [podcastUri]. + */ + fun podcastWithExtraInfo(podcastUri: String): Flow + + /** + * Returns a flow containing the entire collection of podcasts, sorted by the last episode + * publish date for each podcast. + */ + fun podcastsSortedByLastEpisode(limit: Int = Int.MAX_VALUE): Flow> + + /** + * Returns a flow containing a list of all followed podcasts, sorted by the their last + * episode date. + */ + fun followedPodcastsSortedByLastEpisode(limit: Int = Int.MAX_VALUE): Flow> + + /** + * Returns a flow containing a list of podcasts such that its name partially matches + * with the specified keyword + */ + fun searchPodcastByTitle(keyword: String, limit: Int = Int.MAX_VALUE): Flow> + + /** + * Return a flow containing a list of podcast such that it belongs to the any of categories + * specified with categories parameter and its name partially matches with the specified + * keyword. + */ + fun searchPodcastByTitleAndCategories( + keyword: String, + categories: List, + limit: Int = Int.MAX_VALUE, + ): Flow> + + suspend fun togglePodcastFollowed(podcastUri: String) + + suspend fun followPodcast(podcastUri: String) + + suspend fun unfollowPodcast(podcastUri: String) + + /** + * Add a new [Podcast] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + suspend fun addPodcast(podcast: Podcast) + + suspend fun isEmpty(): Boolean +} + +/** + * A data repository for [Podcast] instances. + */ +class LocalPodcastStore constructor( + private val podcastDao: PodcastsDao, + private val podcastFollowedEntryDao: PodcastFollowedEntryDao, + private val transactionRunner: TransactionRunner, +) : PodcastStore { + /** + * Return a flow containing the [Podcast] with the given [uri]. + */ + override fun podcastWithUri(uri: String): Flow { + return podcastDao.podcastWithUri(uri) + } + + /** + * Return a flow containing the [PodcastWithExtraInfo] with the given [podcastUri]. + */ + override fun podcastWithExtraInfo(podcastUri: String): Flow = podcastDao.podcastWithExtraInfo(podcastUri) + + /** + * Returns a flow containing the entire collection of podcasts, sorted by the last episode + * publish date for each podcast. + */ + override fun podcastsSortedByLastEpisode(limit: Int): Flow> { + return podcastDao.podcastsSortedByLastEpisode(limit) + } + + /** + * Returns a flow containing a list of all followed podcasts, sorted by the their last + * episode date. + */ + override fun followedPodcastsSortedByLastEpisode(limit: Int): Flow> { + return podcastDao.followedPodcastsSortedByLastEpisode(limit) + } + + override fun searchPodcastByTitle(keyword: String, limit: Int): Flow> { + return podcastDao.searchPodcastByTitle(keyword, limit) + } + + override fun searchPodcastByTitleAndCategories( + keyword: String, + categories: List, + limit: Int, + ): Flow> { + val categoryIdList = categories.map { it.id } + return podcastDao.searchPodcastByTitleAndCategory(keyword, categoryIdList, limit) + } + + override suspend fun followPodcast(podcastUri: String) { + podcastFollowedEntryDao.insert(PodcastFollowedEntry(podcastUri = podcastUri)) + } + + override suspend fun togglePodcastFollowed(podcastUri: String) = transactionRunner { + if (podcastFollowedEntryDao.isPodcastFollowed(podcastUri)) { + unfollowPodcast(podcastUri) + } else { + followPodcast(podcastUri) + } + } + + override suspend fun unfollowPodcast(podcastUri: String) { + podcastFollowedEntryDao.deleteWithPodcastUri(podcastUri) + } + + /** + * Add a new [Podcast] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + override suspend fun addPodcast(podcast: Podcast) { + podcastDao.insert(podcast) + } + + override suspend fun isEmpty(): Boolean = podcastDao.count() == 0 +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt new file mode 100644 index 0000000000..60dd6c4b09 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.data.database.dao.TransactionRunner +import com.example.jetcaster.core.data.network.PodcastRssResponse +import com.example.jetcaster.core.data.network.PodcastsFetcher +import com.example.jetcaster.core.data.network.SampleFeeds +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Data repository for Podcasts. + */ +class PodcastsRepository @Inject constructor( + private val podcastsFetcher: PodcastsFetcher, + private val podcastStore: PodcastStore, + private val episodeStore: EpisodeStore, + private val categoryStore: CategoryStore, + private val transactionRunner: TransactionRunner, + @Dispatcher(JetcasterDispatchers.Main) mainDispatcher: CoroutineDispatcher, +) { + private var refreshingJob: Job? = null + + private val scope = CoroutineScope(mainDispatcher) + + suspend fun updatePodcasts(force: Boolean) { + if (refreshingJob?.isActive == true) { + refreshingJob?.join() + } else if (force || podcastStore.isEmpty()) { + val job = scope.launch { + // Now fetch the podcasts, and add each to each store + podcastsFetcher(SampleFeeds) + .filter { it is PodcastRssResponse.Success } + .map { it as PodcastRssResponse.Success } + .collect { (podcast, episodes, categories) -> + transactionRunner { + podcastStore.addPodcast(podcast) + episodeStore.addEpisodes(episodes) + + categories.forEach { category -> + // First insert the category + val categoryId = categoryStore.addCategory(category) + // Now we can add the podcast to the category + categoryStore.addPodcastToCategory( + podcastUri = podcast.uri, + categoryId = categoryId, + ) + } + } + } + } + refreshingJob = job + // We need to wait here for the job to finish, otherwise the coroutine completes ~immediatelly + job.join() + } + } +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/util/Flows.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/util/Flows.kt new file mode 100644 index 0000000000..f03fc493fd --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/util/Flows.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.util + +import kotlinx.coroutines.flow.Flow +/** + * Combines 3 flows into a single flow by combining their latest values using the provided transform function. + * + * @param flow The first flow. + * @param flow2 The second flow. + * @param flow3 The third flow. + * @param transform The transform function to combine the latest values of the three flows. + * @return A flow that emits the results of the transform function applied to the latest values of the three flows. + */ +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + transform: suspend (T1, T2, T3, T4, T5) -> R, +): Flow = kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + ) +} +fun combine(flow: Flow, flow2: Flow, transform: suspend (T1, T2) -> R): Flow = + kotlinx.coroutines.flow.combine(flow, flow2) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + ) + } + +/** + * Combines six flows into a single flow by combining their latest values using the provided transform function. + * + * @param flow The first flow. + * @param flow2 The second flow. + * @param flow3 The third flow. + * @param flow4 The fourth flow. + * @param flow5 The fifth flow. + * @param flow6 The sixth flow. + * @param transform The transform function to combine the latest values of the six flows. + * @return A flow that emits the results of the transform function applied to the latest values of the six flows. + */ +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R, +): Flow = kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) +} + +/** + * Combines seven flows into a single flow by combining their latest values using the provided transform function. + * + * @param flow The first flow. + * @param flow2 The second flow. + * @param flow3 The third flow. + * @param flow4 The fourth flow. + * @param flow5 The fifth flow. + * @param flow6 The sixth flow. + * @param flow7 The seventh flow. + * @param transform The transform function to combine the latest values of the seven flows. + * @return A flow that emits the results of the transform function applied to the latest values of the seven flows. + */ +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, +): Flow = kotlinx.coroutines.flow.combine( + flow, + flow2, + flow3, + flow4, + flow5, + flow6, + flow7, +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + ) +} diff --git a/Jetcaster/core/designsystem/.gitignore b/Jetcaster/core/designsystem/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/designsystem/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/designsystem/build.gradle.kts b/Jetcaster/core/designsystem/build.gradle.kts new file mode 100644 index 0000000000..e539916288 --- /dev/null +++ b/Jetcaster/core/designsystem/build.gradle.kts @@ -0,0 +1,78 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.compose) +} + +// TODO(chris): Set up convention plugin +android { + namespace = "com.example.jetcaster.core.designsystem" + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + + defaultConfig { + minSdk = + libs.versions.minSdk + .get() + .toInt() + vectorDrawables.useSupportLibrary = true + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.text) + implementation(libs.coil.kt.compose) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) +} diff --git a/Jetcaster/core/designsystem/consumer-rules.pro b/Jetcaster/core/designsystem/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/designsystem/proguard-rules.pro b/Jetcaster/core/designsystem/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/designsystem/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/designsystem/src/main/AndroidManifest.xml b/Jetcaster/core/designsystem/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/HtmlTextContainer.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/HtmlTextContainer.kt new file mode 100644 index 0000000000..b9e8bd06b2 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/HtmlTextContainer.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.component + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml + +/** + * A container for text that should be HTML formatted. This container will handle building the + * annotated string from [text], and enable text selection if [text] has any selectable element. + */ +@Composable +fun HtmlTextContainer(text: String, content: @Composable (AnnotatedString) -> Unit) { + val annotatedString = remember(key1 = text) { + AnnotatedString.fromHtml(htmlString = text) + } + SelectionContainer { + content(annotatedString) + } +} diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt new file mode 100644 index 0000000000..28bd129d72 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.ContentScale +import coil.compose.AsyncImage + +@Composable +fun ImageBackgroundColorScrim(url: String?, color: Color, modifier: Modifier = Modifier) { + ImageBackground( + url = url, + modifier = modifier, + overlay = { + drawRect(color) + }, + ) +} + +@Composable +fun ImageBackgroundRadialGradientScrim(url: String?, colors: List, modifier: Modifier = Modifier) { + ImageBackground( + url = url, + modifier = modifier, + overlay = { + val brush = Brush.radialGradient( + colors = colors, + center = Offset(0f, size.height), + radius = size.width * 1.5f, + ) + drawRect(brush, blendMode = BlendMode.Multiply) + }, + ) +} + +/** + * Displays an image scaled 150% overlaid by [overlay] + */ +@Composable +fun ImageBackground(url: String?, overlay: DrawScope.() -> Unit, modifier: Modifier = Modifier) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .fillMaxWidth() + .drawWithCache { + onDrawWithContent { + drawContent() + overlay() + } + }, + ) +} diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt new file mode 100644 index 0000000000..1378bc20de --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import com.example.jetcaster.core.designsystem.R + +@Composable +fun PodcastImage( + podcastImageUrl: String, + contentDescription: String?, + modifier: Modifier = Modifier, + // TODO: Remove the nested component modifier when shared elements are applied to entire app + imageModifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + placeholderBrush: Brush = thumbnailPlaceholderDefaultBrush(), +) { + if (LocalInspectionMode.current) { + Box(modifier = modifier.background(MaterialTheme.colorScheme.primary)) + return + } + + var imagePainterState by remember { + mutableStateOf(AsyncImagePainter.State.Empty) + } + + val imageLoader = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(podcastImageUrl) + .crossfade(true) + .build(), + contentScale = contentScale, + onState = { state -> imagePainterState = state }, + ) + + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + when (imagePainterState) { + is AsyncImagePainter.State.Loading, + is AsyncImagePainter.State.Error, + -> { + Image( + painter = painterResource(id = R.drawable.img_empty), + contentDescription = null, + modifier = Modifier + .fillMaxSize(), + ) + } + + else -> { + Box( + modifier = modifier + .background(placeholderBrush) + .fillMaxSize(), + + ) + } + } + + Image( + painter = imageLoader, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier.then(imageModifier), + ) + } +} diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/thumbnailPlaceholder.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/thumbnailPlaceholder.kt new file mode 100644 index 0000000000..80f1f21bca --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/thumbnailPlaceholder.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.component + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import com.example.jetcaster.designsystem.theme.surfaceVariantDark +import com.example.jetcaster.designsystem.theme.surfaceVariantLight + +@Composable +internal fun thumbnailPlaceholderDefaultBrush(color: Color = thumbnailPlaceHolderDefaultColor()): Brush { + return SolidColor(color) +} + +@Composable +private fun thumbnailPlaceHolderDefaultColor(isInDarkMode: Boolean = isSystemInDarkTheme()): Color { + return if (isInDarkMode) { + surfaceVariantDark + } else { + surfaceVariantLight + } +} diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt new file mode 100644 index 0000000000..b8b6376c24 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFFFF792C) +val onPrimaryLight = Color(0xFF626004) +val primaryContainerLight = Color(0xFF313002) +val onPrimaryContainerLight = Color(0xFFFDFCCE) +val secondaryLight = Color(0xFFFFE523) +val onSecondaryLight = Color(0xFF332D00) +val secondaryContainerLight = Color(0xFF998700) +val onSecondaryContainerLight = Color(0xFFFFF9CC) +val tertiaryLight = Color(0xFFFF9AD8) +val onTertiaryLight = Color(0xFF33000A) +val tertiaryContainerLight = Color(0xFF660014) +val onTertiaryContainerLight = Color(0xFF663600) +val errorLight = Color(0xFFFFE5EB) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF93000A) +val backgroundLight = Color(0xFFFEF7FF) +val onBackgroundLight = Color(0xFF1D1B20) +val surfaceLight = Color(0xFFFEF7FF) +val onSurfaceLight = Color(0xFF1D1B20) +val surfaceVariantLight = Color(0xFFE7E0EB) +val onSurfaceVariantLight = Color(0xFF49454E) +val outlineLight = Color(0xFF7A757F) +val outlineVariantLight = Color(0xFFCBC4CF) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFFEFE0D6) +val inverseOnSurfaceLight = Color(0xFF382F28) +val inversePrimaryLight = Color(0xFFD3BCFD) +val surfaceDimLight = Color(0xFF19120C) +val surfaceBrightLight = Color(0xFF413731) +val surfaceContainerLowestLight = Color(0xFF140D08) +val surfaceContainerLowLight = Color(0xFF221A14) +val surfaceContainerLight = Color(0xFF261E18) +val surfaceContainerHighLight = Color(0xFF312822) +val surfaceContainerHighestLight = Color(0xFF3C332C) + +val primaryLightMediumContrast = Color(0xFFFF792C) +val onPrimaryLightMediumContrast = Color(0xFF626004) +val primaryContainerLightMediumContrast = Color(0xFF313002) +val onPrimaryContainerLightMediumContrast = Color(0xFFFDFCCE) +val secondaryLightMediumContrast = Color(0xFFFFE523) +val onSecondaryLightMediumContrast = Color(0xFF332D00) +val secondaryContainerLightMediumContrast = Color(0xFF998700) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFF9CC) +val tertiaryLightMediumContrast = Color(0xFFFF9AD8) +val onTertiaryLightMediumContrast = Color(0xFF33000A) +val tertiaryContainerLightMediumContrast = Color(0xFF660014) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFE5EB) +val errorLightMediumContrast = Color(0xFF740006) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFCF2C27) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFEF7FF) +val onBackgroundLightMediumContrast = Color(0xFF1D1B20) +val surfaceLightMediumContrast = Color(0xFFFEF7FF) +val onSurfaceLightMediumContrast = Color(0xFF121016) +val surfaceVariantLightMediumContrast = Color(0xFFE7E0EB) +val onSurfaceVariantLightMediumContrast = Color(0xFF38353D) +val outlineLightMediumContrast = Color(0xFF55515A) +val outlineVariantLightMediumContrast = Color(0xFF706B75) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF322F35) +val inverseOnSurfaceLightMediumContrast = Color(0xFFF5EFF7) +val inversePrimaryLightMediumContrast = Color(0xFFD3BCFD) +val surfaceDimLightMediumContrast = Color(0xFF19120C) +val surfaceBrightLightMediumContrast = Color(0xFF413731) +val surfaceContainerLowestLightMediumContrast = Color(0xFF140D08) +val surfaceContainerLowLightMediumContrast = Color(0xFF221A14) +val surfaceContainerLightMediumContrast = Color(0xFF261E18) +val surfaceContainerHighLightMediumContrast = Color(0xFF312822) +val surfaceContainerHighestLightMediumContrast = Color(0xFF3C332C) + +val primaryLightHighContrast = Color(0xFF342157) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF523F77) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF30293C) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF4D465A) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF45212C) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF673D48) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF600004) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF98000A) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFEF7FF) +val onBackgroundLightHighContrast = Color(0xFF1D1B20) +val surfaceLightHighContrast = Color(0xFFFEF7FF) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFE7E0EB) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF2E2B33) +val outlineVariantLightHighContrast = Color(0xFF4C4751) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF322F35) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFD3BCFD) +val surfaceDimLightHighContrast = Color(0xFFBCB7BF) +val surfaceBrightLightHighContrast = Color(0xFFFEF7FF) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFF5EFF7) +val surfaceContainerLightHighContrast = Color(0xFFE7E0E8) +val surfaceContainerHighLightHighContrast = Color(0xFFD8D2DA) +val surfaceContainerHighestLightHighContrast = Color(0xFFCAC4CC) + +val primaryDark = Color(0xFFF0FCB0) +val onPrimaryDark = Color(0xFF626004) +val primaryContainerDark = Color(0xFF313002) +val onPrimaryContainerDark = Color(0xFFFDFCCE) +val secondaryDark = Color(0xFFFFE523) +val onSecondaryDark = Color(0xFF332D00) +val secondaryContainerDark = Color(0xFF998700) +val onSecondaryContainerDark = Color(0xFFFFF9CC) +val tertiaryDark = Color(0xFFFF9AD8) +val onTertiaryDark = Color(0xFF33000A) +val tertiaryContainerDark = Color(0xFF660014) +val onTertiaryContainerDark = Color(0xFFFFE5EB) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF151218) +val onBackgroundDark = Color(0xFFE7E0E8) +val surfaceDark = Color(0xFF261604) +val onSurfaceDark = Color(0xFFFBEDE4) +val surfaceVariantDark = Color(0xFF49454E) +val onSurfaceVariantDark = Color(0xFFCBC4CF) +val outlineDark = Color(0xFF948F99) +val outlineVariantDark = Color(0xFF49454E) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE7E0E8) +val inverseOnSurfaceDark = Color(0xFF322F35) +val inversePrimaryDark = Color(0xFF68548E) +val surfaceDimDark = Color(0xFF19120C) +val surfaceBrightDark = Color(0xFF413731) +val surfaceContainerLowestDark = Color(0xFF140D08) +val surfaceContainerLowDark = Color(0xFF221A14) +val surfaceContainerDark = Color(0xFF261E18) +val surfaceContainerHighDark = Color(0xFF312822) +val surfaceContainerHighestDark = Color(0xFF3C332C) + +val primaryDarkMediumContrast = Color(0xFFF0FCB0) +val onPrimaryDarkMediumContrast = Color(0xFF626004) +val primaryContainerDarkMediumContrast = Color(0xFF313002) +val onPrimaryContainerDarkMediumContrast = Color(0xFFFDFCCE) +val secondaryDarkMediumContrast = Color(0xFFFFE523) +val onSecondaryDarkMediumContrast = Color(0xFF332D00) +val secondaryContainerDarkMediumContrast = Color(0xFF998700) +val onSecondaryContainerDarkMediumContrast = Color(0xFFFFF9CC) +val tertiaryDarkMediumContrast = Color(0xFFFF9AD8) +val onTertiaryDarkMediumContrast = Color(0xFF33000A) +val tertiaryContainerDarkMediumContrast = Color(0xFF660014) +val onTertiaryContainerDarkMediumContrast = Color(0xFFFFE5EB) +val errorDarkMediumContrast = Color(0xFFFFD2CC) +val onErrorDarkMediumContrast = Color(0xFF540003) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF151218) +val onBackgroundDarkMediumContrast = Color(0xFFE7E0E8) +val surfaceDarkMediumContrast = Color(0xFF151218) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF49454E) +val onSurfaceVariantDarkMediumContrast = Color(0xFFE1DAE5) +val outlineDarkMediumContrast = Color(0xFFB6B0BA) +val outlineVariantDarkMediumContrast = Color(0xFF948E98) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE7E0E8) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF322F35) +val inversePrimaryDarkMediumContrast = Color(0xFF68548E) +val surfaceDimDarkMediumContrast = Color(0xFF19120C) +val surfaceBrightDarkMediumContrast = Color(0xFF413731) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF140D08) +val surfaceContainerLowDarkMediumContrast = Color(0xFF221A14) +val surfaceContainerDarkMediumContrast = Color(0xFF261E18) +val surfaceContainerHighDarkMediumContrast = Color(0xFF312822) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3C332C) + +val primaryDarkHighContrast = Color(0xFFF0FCB0) +val onPrimaryDarkHighContrast = Color(0xFF626004) +val primaryContainerDarkHighContrast = Color(0xFF313002) +val onPrimaryContainerDarkHighContrast = Color(0xFFFDFCCE) +val secondaryDarkHighContrast = Color(0xFFFFE523) +val onSecondaryDarkHighContrast = Color(0xFF332D00) +val secondaryContainerDarkHighContrast = Color(0xFF998700) +val onSecondaryContainerDarkHighContrast = Color(0xFFFFF9CC) +val tertiaryDarkHighContrast = Color(0xFFFF9AD8) +val onTertiaryDarkHighContrast = Color(0xFF33000A) +val tertiaryContainerDarkHighContrast = Color(0xFF660014) +val onTertiaryContainerDarkHighContrast = Color(0xFFFFE5EB) +val errorDarkHighContrast = Color(0xFFFFECE9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFAEA4) +val onErrorContainerDarkHighContrast = Color(0xFF220001) +val backgroundDarkHighContrast = Color(0xFF151218) +val onBackgroundDarkHighContrast = Color(0xFFE7E0E8) +val surfaceDarkHighContrast = Color(0xFF151218) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF49454E) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFF5EDF9) +val outlineVariantDarkHighContrast = Color(0xFFC7C0CB) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE7E0E8) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF513E75) +val surfaceDimDarkHighContrast = Color(0xFF19120C) +val surfaceBrightDarkHighContrast = Color(0xFF413731) +val surfaceContainerLowestDarkHighContrast = Color(0xFF140D08) +val surfaceContainerLowDarkHighContrast = Color(0xFF221A14) +val surfaceContainerDarkHighContrast = Color(0xFF261E18) +val surfaceContainerHighDarkHighContrast = Color(0xFF312822) +val surfaceContainerHighestDarkHighContrast = Color(0xFF3C332C) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt similarity index 90% rename from Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt rename to Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt index 5242395c1c..4340443cbf 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt @@ -14,8 +14,8 @@ * limitations under the License. */ -package com.example.jetcaster.ui.theme +package com.example.jetcaster.designsystem.theme import androidx.compose.ui.unit.dp -val Keyline1 = 24.dp +val Keyline1 = 16.dp diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt new file mode 100644 index 0000000000..a5f74d71de --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val JetcasterShapes = Shapes( + small = RoundedCornerShape(percent = 50), + medium = RoundedCornerShape(size = 8.dp), + large = RoundedCornerShape(size = 16.dp), +) diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt new file mode 100644 index 0000000000..be704c3eba --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp + +val JetcasterTypography = androidx.compose.material3.Typography( + displayLarge = TextStyle( + fontSize = 64.sp, + lineHeight = 56.sp, + fontFamily = RobotoFlex, + fontWeight = FontWeight(738), + textAlign = TextAlign.Center, + ), + displayMedium = TextStyle( + fontFamily = RobotoFlex, + fontSize = 45.sp, + fontWeight = FontWeight.W400, + lineHeight = 52.sp, + ), + displaySmall = TextStyle( + fontFamily = Montserrat, + fontSize = 36.sp, + fontWeight = FontWeight.W400, + lineHeight = 44.sp, + ), + headlineLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 32.sp, + fontWeight = FontWeight.W500, + lineHeight = 40.sp, + ), + headlineMedium = TextStyle( + fontFamily = Montserrat, + fontSize = 28.sp, + fontWeight = FontWeight.W500, + lineHeight = 36.sp, + ), + headlineSmall = TextStyle( + fontFamily = Montserrat, + fontSize = 24.sp, + fontWeight = FontWeight.W500, + lineHeight = 32.sp, + ), + titleLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 22.sp, + fontWeight = FontWeight.W400, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontFamily = Montserrat, + fontSize = 16.sp, + fontWeight = FontWeight.W500, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontFamily = Montserrat, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontFamily = Montserrat, + fontSize = 12.sp, + fontWeight = FontWeight.W500, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontFamily = Montserrat, + fontSize = 11.sp, + fontWeight = FontWeight.W500, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + bodyLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 16.sp, + fontWeight = FontWeight.W500, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontFamily = Montserrat, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontFamily = Montserrat, + fontSize = 12.sp, + fontWeight = FontWeight.W500, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), +) diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt new file mode 100644 index 0000000000..4f494ccb21 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme + +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import com.example.jetcaster.core.designsystem.R + +val Montserrat = FontFamily( + Font(R.font.montserrat_light, FontWeight.Light), + Font(R.font.montserrat_regular, FontWeight.Normal), + Font(R.font.montserrat_medium, FontWeight.Medium), + Font(R.font.montserrat_semibold, FontWeight.SemiBold), +) + +val RobotoFlex = FontFamily( + Font(R.font.roboto_flex), +) diff --git a/Jetcaster/core/designsystem/src/main/res/drawable/img_empty.xml b/Jetcaster/core/designsystem/src/main/res/drawable/img_empty.xml new file mode 100644 index 0000000000..46b27de1d1 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/res/drawable/img_empty.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/app/src/main/res/font/montserrat_light.ttf b/Jetcaster/core/designsystem/src/main/res/font/montserrat_light.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_light.ttf rename to Jetcaster/core/designsystem/src/main/res/font/montserrat_light.ttf diff --git a/Jetcaster/app/src/main/res/font/montserrat_medium.ttf b/Jetcaster/core/designsystem/src/main/res/font/montserrat_medium.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_medium.ttf rename to Jetcaster/core/designsystem/src/main/res/font/montserrat_medium.ttf diff --git a/Jetcaster/app/src/main/res/font/montserrat_regular.ttf b/Jetcaster/core/designsystem/src/main/res/font/montserrat_regular.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_regular.ttf rename to Jetcaster/core/designsystem/src/main/res/font/montserrat_regular.ttf diff --git a/JetNews/app/src/main/res/font/montserrat_semibold.ttf b/Jetcaster/core/designsystem/src/main/res/font/montserrat_semibold.ttf similarity index 100% rename from JetNews/app/src/main/res/font/montserrat_semibold.ttf rename to Jetcaster/core/designsystem/src/main/res/font/montserrat_semibold.ttf diff --git a/Jetcaster/core/designsystem/src/main/res/font/roboto_flex.ttf b/Jetcaster/core/designsystem/src/main/res/font/roboto_flex.ttf new file mode 100644 index 0000000000..2e5c2a26a7 Binary files /dev/null and b/Jetcaster/core/designsystem/src/main/res/font/roboto_flex.ttf differ diff --git a/Jetcaster/core/designsystem/src/main/res/values-night/colors.xml b/Jetcaster/core/designsystem/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..148f321a7a --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + #FF1A120A + #FF42372D + diff --git a/Jetcaster/core/designsystem/src/main/res/values/colors.xml b/Jetcaster/core/designsystem/src/main/res/values/colors.xml new file mode 100644 index 0000000000..10f401c721 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FFFFF8F4 + #FFFFF8F4 + diff --git a/Jetcaster/core/domain-testing/.gitignore b/Jetcaster/core/domain-testing/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/domain-testing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/domain-testing/build.gradle.kts b/Jetcaster/core/domain-testing/build.gradle.kts new file mode 100644 index 0000000000..631a376944 --- /dev/null +++ b/Jetcaster/core/domain-testing/build.gradle.kts @@ -0,0 +1,70 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.example.jetcaster.core.domain.testing" + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + + defaultConfig { + minSdk = + libs.versions.minSdk + .get() + .toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + implementation(projects.core.domain) + + coreLibraryDesugaring(libs.core.jdk.desugaring) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) +} diff --git a/Jetcaster/core/domain-testing/consumer-rules.pro b/Jetcaster/core/domain-testing/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/domain-testing/proguard-rules.pro b/Jetcaster/core/domain-testing/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/domain-testing/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/domain-testing/src/main/AndroidManifest.xml b/Jetcaster/core/domain-testing/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a5918e68ab --- /dev/null +++ b/Jetcaster/core/domain-testing/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Jetcaster/core/domain-testing/src/main/java/com/example/jetcaster/core/domain/testing/PreviewData.kt b/Jetcaster/core/domain-testing/src/main/java/com/example/jetcaster/core/domain/testing/PreviewData.kt new file mode 100644 index 0000000000..c53c717137 --- /dev/null +++ b/Jetcaster/core/domain-testing/src/main/java/com/example/jetcaster/core/domain/testing/PreviewData.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain.testing + +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.model.PodcastToEpisodeInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import java.time.OffsetDateTime +import java.time.ZoneOffset + +val PreviewCategories = listOf( + CategoryInfo(id = 1, name = "Crime"), + CategoryInfo(id = 2, name = "News"), + CategoryInfo(id = 3, name = "Comedy"), +) + +val PreviewPodcasts = listOf( + PodcastInfo( + uri = "fakeUri://podcast/1", + title = "Android Developers Backstage", + author = "Android Developers", + isSubscribed = true, + lastEpisodeDate = OffsetDateTime.now(), + ), + PodcastInfo( + uri = "fakeUri://podcast/2", + title = "Google Developers podcast", + author = "Google Developers", + lastEpisodeDate = OffsetDateTime.now(), + ), +) + +val PreviewEpisodes = listOf( + EpisodeInfo( + uri = "fakeUri://episode/1", + title = "Episode 140: Lorem ipsum dolor", + summary = "In this episode, Romain, Chet and Tor talked with Mady Melor and Artur " + + "Tsurkan from the System UI team about... Bubbles!", + published = OffsetDateTime.of( + 2020, 6, 2, 9, + 27, 0, 0, ZoneOffset.of("-0800"), + ), + ), +) + +val PreviewPlayerEpisodes = listOf( + PlayerEpisode( + PreviewPodcasts[0], + PreviewEpisodes[0], + ), +) + +val PreviewPodcastEpisodes = listOf( + PodcastToEpisodeInfo( + podcast = PreviewPodcasts[0], + episode = PreviewEpisodes[0], + ), +) diff --git a/Jetcaster/core/domain/.gitignore b/Jetcaster/core/domain/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/domain/build.gradle.kts b/Jetcaster/core/domain/build.gradle.kts new file mode 100644 index 0000000000..da640fa411 --- /dev/null +++ b/Jetcaster/core/domain/build.gradle.kts @@ -0,0 +1,77 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + namespace = "com.example.jetcaster.core.domain" + + defaultConfig { + minSdk = + libs.versions.minSdk + .get() + .toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + coreLibraryDesugaring(libs.core.jdk.desugaring) + implementation(projects.core.data) + implementation(projects.core.dataTesting) + + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/Jetcaster/core/domain/consumer-rules.pro b/Jetcaster/core/domain/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/domain/proguard-rules.pro b/Jetcaster/core/domain/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/domain/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/domain/src/main/AndroidManifest.xml b/Jetcaster/core/domain/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/domain/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/di/DomainDiModule.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/di/DomainDiModule.kt new file mode 100644 index 0000000000..7cd2ce606b --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/di/DomainDiModule.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.di + +import com.example.jetcaster.core.data.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.MockEpisodePlayer +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher + +@Module +@InstallIn(SingletonComponent::class) +object DomainDiModule { + @Provides + @Singleton + fun provideEpisodePlayer(@Dispatcher(JetcasterDispatchers.Main) mainDispatcher: CoroutineDispatcher): EpisodePlayer = + MockEpisodePlayer(mainDispatcher) +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/FilterableCategoriesUseCase.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/FilterableCategoriesUseCase.kt new file mode 100644 index 0000000000..255f3458ef --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/FilterableCategoriesUseCase.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.asExternalModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Use case for categories that can be used to filter podcasts. + */ +class FilterableCategoriesUseCase @Inject constructor(private val categoryStore: CategoryStore) { + /** + * Created a [FilterableCategoriesModel] from the list of categories in [categoryStore]. + * @param selectedCategory the currently selected category. If null, the first category + * returned by the backing category list will be selected in the returned + * FilterableCategoriesModel + */ + operator fun invoke(selectedCategory: CategoryInfo?): Flow = categoryStore.categoriesSortedByPodcastCount() + .map { categories -> + FilterableCategoriesModel( + categories = categories.map { it.asExternalModel() }, + selectedCategory = selectedCategory + ?: categories.firstOrNull()?.asExternalModel(), + ) + } +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCase.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCase.kt new file mode 100644 index 0000000000..344c65b94b --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCase.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +/** + * A use case which returns all the latest episodes from all the podcasts the user follows. + */ +class GetLatestFollowedEpisodesUseCase @Inject constructor( + private val episodeStore: EpisodeStore, + private val podcastStore: PodcastStore, +) { + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(): Flow> = podcastStore.followedPodcastsSortedByLastEpisode() + .flatMapLatest { followedPodcasts -> + episodeStore.episodesInPodcasts( + followedPodcasts.map { it.podcast.uri }, + followedPodcasts.size * 5, + ) + } +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCase.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCase.kt new file mode 100644 index 0000000000..dbfbba91b1 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCase.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.model.asPodcastToEpisodeInfo +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf + +/** + * A use case which returns top podcasts and matching episodes in a given [Category]. + */ +class PodcastCategoryFilterUseCase @Inject constructor(private val categoryStore: CategoryStore) { + operator fun invoke(category: CategoryInfo?): Flow { + if (category == null) { + return flowOf(PodcastCategoryFilterResult()) + } + + val recentPodcastsFlow = categoryStore.podcastsInCategorySortedByPodcastCount( + category.id, + limit = 10, + ) + + val episodesFlow = categoryStore.episodesFromPodcastsInCategory( + category.id, + limit = 20, + ) + + // Combine our flows and collect them into the view state StateFlow + return combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes -> + PodcastCategoryFilterResult( + topPodcasts = topPodcasts.map { it.asExternalModel() }, + episodes = episodes.map { it.asPodcastToEpisodeInfo() }, + ) + } + } +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt new file mode 100644 index 0000000000..7ef3687559 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.data.database.model.Category + +@Immutable +data class CategoryInfo(val id: Long, val name: String) + +const val CategoryTechnology = "Technology" + +fun Category.asExternalModel() = CategoryInfo( + id = id, + name = name, +) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt new file mode 100644 index 0000000000..5fcb4226ea --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.data.database.model.Episode +import java.time.Duration +import java.time.OffsetDateTime + +/** + * External data layer representation of an episode. + */ + +@Immutable +data class EpisodeInfo( + val uri: String = "", + val podcastUri: String = "", + val title: String = "", + val subTitle: String = "", + val summary: String = "", + val author: String = "", + val published: OffsetDateTime = OffsetDateTime.MIN, + val duration: Duration? = null, + val mediaUrls: List = emptyList(), +) + +fun Episode.asExternalModel(): EpisodeInfo = EpisodeInfo( + uri = uri, + podcastUri = podcastUri, + title = title, + subTitle = subtitle ?: "", + summary = summary ?: "", + author = author ?: "", + published = published, + duration = duration, + mediaUrls = mediaUrls, +) + +fun EpisodeInfo.asDaoModel(): Episode = Episode( + uri = uri, + title = title, + subtitle = subTitle, + summary = summary, + author = author, + published = published, + duration = duration, + podcastUri = podcastUri, + mediaUrls = mediaUrls, +) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt new file mode 100644 index 0000000000..d8975dbbeb --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import androidx.compose.runtime.Immutable + +/** + * Model holding a list of categories and a selected category in the collection + */ + +@Immutable +data class FilterableCategoriesModel(val categories: List = emptyList(), val selectedCategory: CategoryInfo? = null) { + val isEmpty = categories.isEmpty() || selectedCategory == null +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt new file mode 100644 index 0000000000..d268ea97c4 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +data class LibraryInfo(val episodes: List = emptyList()) : List by episodes diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt new file mode 100644 index 0000000000..2520a01d94 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import androidx.compose.runtime.Immutable + +/** + * A model holding top podcasts and matching episodes when filtering based on a category. + */ +@Immutable +data class PodcastCategoryFilterResult( + val topPodcasts: List = emptyList(), + val episodes: List = emptyList(), +) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt new file mode 100644 index 0000000000..7380c6f646 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import java.time.OffsetDateTime + +/** + * External data layer representation of a podcast. + */ +@Immutable +data class PodcastInfo( + val uri: String = "", + val title: String = "", + val author: String = "", + val imageUrl: String = "", + val description: String = "", + val isSubscribed: Boolean? = null, + val lastEpisodeDate: OffsetDateTime? = null, +) + +fun Podcast.asExternalModel(): PodcastInfo = PodcastInfo( + uri = this.uri, + title = this.title, + author = this.author ?: "", + imageUrl = this.imageUrl ?: "", + description = this.description ?: "", +) + +fun PodcastWithExtraInfo.asExternalModel(): PodcastInfo = this.podcast.asExternalModel().copy( + isSubscribed = isFollowed, + lastEpisodeDate = lastEpisodeDate, +) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastToEpisodeInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastToEpisodeInfo.kt new file mode 100644 index 0000000000..86aa1ccdea --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastToEpisodeInfo.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast + +@Immutable +data class PodcastToEpisodeInfo(val episode: EpisodeInfo, val podcast: PodcastInfo) + +fun EpisodeToPodcast.asPodcastToEpisodeInfo(): PodcastToEpisodeInfo = PodcastToEpisodeInfo( + episode = episode.asExternalModel(), + podcast = podcast.asExternalModel(), +) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt new file mode 100644 index 0000000000..874c5e360d --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.player + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.player.model.PlayerEpisode +import java.time.Duration +import kotlinx.coroutines.flow.StateFlow + +val DefaultPlaybackSpeed = Duration.ofSeconds(1) + +@Immutable +data class EpisodePlayerState( + val currentEpisode: PlayerEpisode? = null, + val queue: List = emptyList(), + val playbackSpeed: Duration = DefaultPlaybackSpeed, + val isPlaying: Boolean = false, + val timeElapsed: Duration = Duration.ZERO, +) + +/** + * Interface definition for an episode player defining high-level functions such as queuing + * episodes, playing an episode, pausing, seeking, etc. + */ +interface EpisodePlayer { + + /** + * A StateFlow that emits the [EpisodePlayerState] as controls as invoked on this player. + */ + val playerState: StateFlow + + /** + * Gets the current episode playing, or to be played, by this player. + */ + var currentEpisode: PlayerEpisode? + + /** + * The speed of which the player increments + */ + var playerSpeed: Duration + + fun addToQueue(episode: PlayerEpisode) + + /* + * Flushes the queue + */ + fun removeAllFromQueue() + + /** + * Plays the current episode + */ + fun play() + + /** + * Plays the specified episode + */ + fun play(playerEpisode: PlayerEpisode) + + /** + * Plays the specified list of episodes + */ + fun play(playerEpisodes: List) + + /** + * Pauses the currently played episode + */ + fun pause() + + /** + * Stops the currently played episode + */ + fun stop() + + /** + * Plays another episode in the queue (if available) + */ + fun next() + + /** + * Plays the previous episode in the queue (if available). Or if an episode is currently + * playing this will start the episode from the beginning + */ + fun previous() + + /** + * Advances a currently played episode by a given time interval specified in [duration]. + */ + fun advanceBy(duration: Duration) + + /** + * Rewinds a currently played episode by a given time interval specified in [duration]. + */ + fun rewindBy(duration: Duration) + + /** + * Signal that user started seeking. + */ + fun onSeekingStarted() + + /** + * Seeks to a given time interval specified in [duration]. + */ + fun onSeekingFinished(duration: Duration) + + /** + * Increases the speed of Player playback by a given time specified in [duration]. + */ + fun increaseSpeed(speed: Duration = Duration.ofMillis(500)) + + /** + * Decreases the speed of Player playback by a given time specified in [duration]. + */ + fun decreaseSpeed(speed: Duration = Duration.ofMillis(500)) +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt new file mode 100644 index 0000000000..ab7c17c9fb --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -0,0 +1,223 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.player + +import com.example.jetcaster.core.player.model.PlayerEpisode +import java.time.Duration +import kotlin.reflect.KProperty +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class MockEpisodePlayer(private val mainDispatcher: CoroutineDispatcher) : EpisodePlayer { + + private val _playerState = MutableStateFlow(EpisodePlayerState()) + private val _currentEpisode = MutableStateFlow(null) + private val queue = MutableStateFlow>(emptyList()) + private val isPlaying = MutableStateFlow(false) + private val timeElapsed = MutableStateFlow(Duration.ZERO) + private val _playerSpeed = MutableStateFlow(DefaultPlaybackSpeed) + private val coroutineScope = CoroutineScope(mainDispatcher) + + private var timerJob: Job? = null + + init { + coroutineScope.launch { + // Combine streams here + combine( + _currentEpisode, + queue, + isPlaying, + timeElapsed, + _playerSpeed, + ) { currentEpisode, queue, isPlaying, timeElapsed, playerSpeed -> + EpisodePlayerState( + currentEpisode = currentEpisode, + queue = queue, + isPlaying = isPlaying, + timeElapsed = timeElapsed, + playbackSpeed = playerSpeed, + ) + }.catch { + // TODO handle error state + throw it + }.collect { + _playerState.value = it + } + } + } + + override var playerSpeed: Duration = _playerSpeed.value + + override val playerState: StateFlow = _playerState.asStateFlow() + + override var currentEpisode: PlayerEpisode? by _currentEpisode + override fun addToQueue(episode: PlayerEpisode) { + queue.update { + it + episode + } + } + + override fun removeAllFromQueue() { + queue.value = emptyList() + } + + override fun play() { + // Do nothing if already playing + if (isPlaying.value) { + return + } + + val episode = _currentEpisode.value ?: return + + isPlaying.value = true + timerJob = coroutineScope.launch { + // Increment timer by a second + while (isActive && timeElapsed.value < episode.duration) { + delay(playerSpeed.toMillis()) + timeElapsed.update { it + playerSpeed } + } + + // Once done playing, see if + isPlaying.value = false + timeElapsed.value = Duration.ZERO + + if (hasNext()) { + next() + } + } + } + + override fun play(playerEpisode: PlayerEpisode) { + play(listOf(playerEpisode)) + } + + override fun play(playerEpisodes: List) { + if (isPlaying.value) { + pause() + } + + // Keep the currently playing episode in the queue + val playingEpisode = _currentEpisode.value + var previousList: List = emptyList() + queue.update { queue -> + playerEpisodes.map { episode -> + if (queue.contains(episode)) { + val mutableList = queue.toMutableList() + mutableList.remove(episode) + previousList = mutableList + } else { + previousList = queue + } + } + if (playingEpisode != null) { + playerEpisodes + listOf(playingEpisode) + previousList + } else { + playerEpisodes + previousList + } + } + + next() + } + + override fun pause() { + isPlaying.value = false + + timerJob?.cancel() + timerJob = null + } + + override fun stop() { + isPlaying.value = false + timeElapsed.value = Duration.ZERO + + timerJob?.cancel() + timerJob = null + } + + override fun advanceBy(duration: Duration) { + val currentEpisodeDuration = _currentEpisode.value?.duration ?: return + timeElapsed.update { + (it + duration).coerceAtMost(currentEpisodeDuration) + } + } + + override fun rewindBy(duration: Duration) { + timeElapsed.update { + (it - duration).coerceAtLeast(Duration.ZERO) + } + } + + override fun onSeekingStarted() { + // Need to pause the player so that it doesn't compete with timeline progression. + pause() + } + + override fun onSeekingFinished(duration: Duration) { + val currentEpisodeDuration = _currentEpisode.value?.duration ?: return + timeElapsed.update { duration.coerceIn(Duration.ZERO, currentEpisodeDuration) } + play() + } + + override fun increaseSpeed(speed: Duration) { + _playerSpeed.value += speed + } + + override fun decreaseSpeed(speed: Duration) { + _playerSpeed.value -= speed + } + + override fun next() { + val q = queue.value + if (q.isEmpty()) { + return + } + + timeElapsed.value = Duration.ZERO + val nextEpisode = q[0] + currentEpisode = nextEpisode + queue.value = q - nextEpisode + play() + } + + override fun previous() { + timeElapsed.value = Duration.ZERO + isPlaying.value = false + timerJob?.cancel() + timerJob = null + } + + private fun hasNext(): Boolean { + return queue.value.isNotEmpty() + } +} + +// Used to enable property delegation +private operator fun MutableStateFlow.setValue(thisObj: Any?, property: KProperty<*>, value: T) { + this.value = value +} + +private operator fun MutableStateFlow.getValue(thisObj: Any?, property: KProperty<*>): T = this.value diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/model/PlayerEpisode.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/model/PlayerEpisode.kt new file mode 100644 index 0000000000..f99f9a0393 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/model/PlayerEpisode.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.player.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import java.time.Duration +import java.time.OffsetDateTime + +/** + * Episode data with necessary information to be used within a player. + */ + +@Immutable +data class PlayerEpisode( + val uri: String = "", + val title: String = "", + val subTitle: String = "", + val published: OffsetDateTime = OffsetDateTime.MIN, + val duration: Duration? = null, + val podcastName: String = "", + val author: String = "", + val summary: String = "", + val podcastImageUrl: String = "", + val mediaUrls: List = emptyList(), +) { + constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo) : this( + title = episodeInfo.title, + subTitle = episodeInfo.subTitle, + published = episodeInfo.published, + duration = episodeInfo.duration, + podcastName = podcastInfo.title, + author = episodeInfo.author, + summary = episodeInfo.summary, + podcastImageUrl = podcastInfo.imageUrl, + uri = episodeInfo.uri, + mediaUrls = episodeInfo.mediaUrls, + ) +} + +fun EpisodeToPodcast.toPlayerEpisode(): PlayerEpisode = PlayerEpisode( + uri = episode.uri, + title = episode.title, + subTitle = episode.subtitle ?: "", + published = episode.published, + duration = episode.duration, + podcastName = podcast.title, + author = episode.author ?: podcast.author ?: "", + summary = episode.summary ?: "", + podcastImageUrl = podcast.imageUrl ?: "", + mediaUrls = episode.mediaUrls, +) diff --git a/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/FilterableCategoriesUseCaseTest.kt b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/FilterableCategoriesUseCaseTest.kt new file mode 100644 index 0000000000..71ef508c2c --- /dev/null +++ b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/FilterableCategoriesUseCaseTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.testing.repository.TestCategoryStore +import com.example.jetcaster.core.model.asExternalModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class FilterableCategoriesUseCaseTest { + + private val categoriesStore = TestCategoryStore() + private val testCategories = listOf( + Category(1, "News"), + Category(2, "Arts"), + Category(4, "Technology"), + Category(2, "TV & Film"), + ) + + val useCase = FilterableCategoriesUseCase( + categoryStore = categoriesStore, + ) + + @Before + fun setUp() { + categoriesStore.setCategories(testCategories) + } + + @Test + fun whenNoSelectedCategory_onEmptySelectedCategoryInvoked() = runTest { + val filterableCategories = useCase(null).first() + assertEquals( + filterableCategories.categories[0], + filterableCategories.selectedCategory, + ) + } + + @Test + fun whenSelectedCategory_correctFilterableCategoryIsSelected() = runTest { + val selectedCategory = testCategories[2] + val filterableCategories = useCase(selectedCategory.asExternalModel()).first() + assertEquals( + selectedCategory.asExternalModel(), + filterableCategories.selectedCategory, + ) + } +} diff --git a/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCaseTest.kt b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCaseTest.kt new file mode 100644 index 0000000000..11b2d31535 --- /dev/null +++ b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCaseTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.testing.repository.TestEpisodeStore +import com.example.jetcaster.core.data.testing.repository.TestPodcastStore +import java.time.Duration +import java.time.OffsetDateTime +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class GetLatestFollowedEpisodesUseCaseTest { + + private val episodeStore = TestEpisodeStore() + private val podcastStore = TestPodcastStore() + + val useCase = GetLatestFollowedEpisodesUseCase( + episodeStore = episodeStore, + podcastStore = podcastStore, + ) + + val testEpisodes = listOf( + Episode( + uri = "", + podcastUri = testPodcasts[0].podcast.uri, + title = "title1", + published = OffsetDateTime.MIN, + subtitle = "subtitle1", + summary = "summary1", + author = "author1", + duration = Duration.ofMinutes(1), + mediaUrls = listOf("Url1"), + ), + Episode( + uri = "", + podcastUri = testPodcasts[0].podcast.uri, + title = "title2", + published = OffsetDateTime.now(), + subtitle = "subtitle2", + summary = "summary2", + author = "author2", + duration = Duration.ofMinutes(1), + mediaUrls = listOf("Url1"), + ), + Episode( + uri = "", + podcastUri = testPodcasts[1].podcast.uri, + title = "title3", + published = OffsetDateTime.MAX, + subtitle = "subtitle3", + summary = "summary3", + author = "author3", + duration = Duration.ofMinutes(1), + mediaUrls = listOf("Url1"), + ), + ) + + @Test + fun whenNoFollowedPodcasts_emptyFlow() = runTest { + val result = useCase() + + episodeStore.addEpisodes(testEpisodes) + testPodcasts.forEach { + podcastStore.addPodcast(it.podcast) + } + + assertTrue(result.first().isEmpty()) + } + + @Test + fun whenFollowedPodcasts_nonEmptyFlow() = runTest { + val result = useCase() + + episodeStore.addEpisodes(testEpisodes) + testPodcasts.forEach { + podcastStore.addPodcast(it.podcast) + } + podcastStore.togglePodcastFollowed(testPodcasts[0].podcast.uri) + + assertTrue(result.first().isNotEmpty()) + } + + @Test + fun whenFollowedPodcasts_sortedByPublished() = runTest { + val result = useCase() + + episodeStore.addEpisodes(testEpisodes) + testPodcasts.forEach { + podcastStore.addPodcast(it.podcast) + } + podcastStore.togglePodcastFollowed(testPodcasts[0].podcast.uri) + + result.first().zipWithNext { ep1, ep2 -> + ep1.episode.published > ep2.episode.published + }.all { it } + } +} diff --git a/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCaseTest.kt new file mode 100644 index 0000000000..e8ac3f4fc9 --- /dev/null +++ b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCaseTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.testing.repository.TestCategoryStore +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.model.asPodcastToEpisodeInfo +import java.time.Duration +import java.time.OffsetDateTime +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PodcastCategoryFilterUseCaseTest { + + private val categoriesStore = TestCategoryStore() + private val testEpisodeToPodcast = listOf( + EpisodeToPodcast().apply { + episode = Episode( + "", + "", + "Episode 1", + published = OffsetDateTime.now(), + subtitle = "subtitle1", + summary = "summary1", + author = "author1", + duration = Duration.ofMinutes(1), + mediaUrls = listOf("Url1"), + ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 1", + ), + ) + }, + EpisodeToPodcast().apply { + episode = Episode( + "", + "", + "Episode 2", + published = OffsetDateTime.now(), + subtitle = "subtitle2", + summary = "summary2", + author = "author2", + duration = Duration.ofMinutes(1), + mediaUrls = listOf("Url1"), + ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 2", + ), + ) + }, + EpisodeToPodcast().apply { + episode = Episode( + "", + "", + "Episode 3", + published = OffsetDateTime.now(), + subtitle = "subtitle3", + summary = "summary3", + author = "author2", + duration = Duration.ofMinutes(1), + mediaUrls = listOf("Url1"), + ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 3", + ), + ) + }, + ) + private val testCategory = Category(1, "Technology") + + val useCase = PodcastCategoryFilterUseCase( + categoryStore = categoriesStore, + ) + + @Test + fun whenCategoryNull_emptyFlow() = runTest { + val resultFlow = useCase(null) + + categoriesStore.setEpisodesFromPodcast(testCategory.id, testEpisodeToPodcast) + categoriesStore.setPodcastsInCategory(testCategory.id, testPodcasts) + + val result = resultFlow.first() + assertTrue(result.topPodcasts.isEmpty()) + assertTrue(result.episodes.isEmpty()) + } + + @Test + fun whenCategoryNotNull_validFlow() = runTest { + val resultFlow = useCase(testCategory.asExternalModel()) + + categoriesStore.setEpisodesFromPodcast(testCategory.id, testEpisodeToPodcast) + categoriesStore.setPodcastsInCategory(testCategory.id, testPodcasts) + + val result = resultFlow.first() + assertEquals( + testPodcasts.map { it.asExternalModel() }, + result.topPodcasts, + ) + assertEquals( + testEpisodeToPodcast.map { it.asPodcastToEpisodeInfo() }, + result.episodes, + ) + } + + @Test + fun whenCategoryInfoNotNull_verifyLimitFlow() = runTest { + val resultFlow = useCase(testCategory.asExternalModel()) + + categoriesStore.setEpisodesFromPodcast( + testCategory.id, + List(8) { testEpisodeToPodcast }.flatten(), + ) + categoriesStore.setPodcastsInCategory( + testCategory.id, + List(4) { testPodcasts }.flatten(), + ) + + val result = resultFlow.first() + assertEquals(20, result.episodes.size) + assertEquals(10, result.topPodcasts.size) + } +} + +val testPodcasts = listOf( + PodcastWithExtraInfo().apply { + podcast = Podcast(uri = "nia", title = "Now in Android") + }, + PodcastWithExtraInfo().apply { + podcast = Podcast(uri = "adb", title = "Android Developers Backstage") + }, + PodcastWithExtraInfo().apply { + podcast = Podcast(uri = "techcrunch", title = "Techcrunch") + }, +) diff --git a/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/player/MockEpisodePlayerTest.kt b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/player/MockEpisodePlayerTest.kt new file mode 100644 index 0000000000..e43e47c972 --- /dev/null +++ b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/player/MockEpisodePlayerTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain.player + +import com.example.jetcaster.core.player.MockEpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import java.time.Duration +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MockEpisodePlayerTest { + + private val testDispatcher = StandardTestDispatcher() + private val mockEpisodePlayer = MockEpisodePlayer(testDispatcher) + private val testEpisodes = listOf( + PlayerEpisode( + uri = "uri1", + duration = Duration.ofSeconds(60), + ), + PlayerEpisode( + uri = "uri2", + duration = Duration.ofSeconds(60), + ), + PlayerEpisode( + uri = "uri3", + duration = Duration.ofSeconds(60), + ), + ) + + @Test + fun whenPlay_incrementsByPlaySpeed() = runTest(testDispatcher) { + val playSpeed = Duration.ofSeconds(2) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60), + ) + mockEpisodePlayer.currentEpisode = currEpisode + mockEpisodePlayer.playerSpeed = playSpeed + + mockEpisodePlayer.play() + advanceTimeBy(playSpeed.toMillis() + 300) + + assertEquals(playSpeed, mockEpisodePlayer.playerState.value.timeElapsed) + } + + @Test + fun whenPlayDone_playerAutoPlaysNextEpisode() = runTest(testDispatcher) { + val duration = Duration.ofSeconds(60) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = duration, + ) + mockEpisodePlayer.currentEpisode = currEpisode + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(duration.toMillis() + 1) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenNext_queueIsNotEmpty_autoPlaysNextEpisode() = runTest(testDispatcher) { + val duration = Duration.ofSeconds(60) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = duration, + ) + + mockEpisodePlayer.currentEpisode = currEpisode + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertTrue(mockEpisodePlayer.playerState.value.isPlaying) + } + @Test + fun whenPlayListOfEpisodes_playerAutoPlaysNextEpisode() = runTest(testDispatcher) { + val duration = Duration.ofSeconds(60) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = duration, + ) + val firstEpisodeFromList = PlayerEpisode( + uri = "firstEpisodeFromList", + duration = duration, + ) + val secondEpisodeFromList = PlayerEpisode( + uri = "secondEpisodeFromList", + duration = duration, + ) + val episodeListToBeAddedToTheQueue: List = listOf( + firstEpisodeFromList, secondEpisodeFromList, + ) + mockEpisodePlayer.currentEpisode = currEpisode + + mockEpisodePlayer.play(episodeListToBeAddedToTheQueue) + assertEquals(firstEpisodeFromList, mockEpisodePlayer.currentEpisode) + + advanceTimeBy(duration.toMillis() + 1) + assertEquals(secondEpisodeFromList, mockEpisodePlayer.currentEpisode) + + advanceTimeBy(duration.toMillis() + 1) + assertEquals(currEpisode, mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenNext_queueIsEmpty_doesNothing() { + val episode = testEpisodes[0] + mockEpisodePlayer.currentEpisode = episode + mockEpisodePlayer.play() + + mockEpisodePlayer.next() + + assertEquals(episode, mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenAddToQueue_queueIsNotEmpty() = runTest(testDispatcher) { + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + advanceUntilIdle() + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size, queue.size) + testEpisodes.forEachIndexed { index, playerEpisode -> + assertEquals(playerEpisode, queue[index]) + } + } + + @Test + fun whenNext_queueIsNotEmpty_removeFromQueue() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60), + ) + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(100) + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size - 1, queue.size) + } + + @Test + fun whenNext_queueIsNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60), + ) + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(100) + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size - 1, queue.size) + } + + @Test + fun whenPrevious_queueIsEmpty_resetSameEpisode() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = testEpisodes[0] + mockEpisodePlayer.play() + advanceTimeBy(1000L) + + mockEpisodePlayer.previous() + assertEquals(0, mockEpisodePlayer.playerState.value.timeElapsed.toMillis()) + assertEquals(testEpisodes[0], mockEpisodePlayer.currentEpisode) + } +} diff --git a/Jetcaster/debug.keystore b/Jetcaster/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Jetcaster/debug.keystore and /dev/null differ diff --git a/Jetcaster/debug_2.keystore b/Jetcaster/debug_2.keystore new file mode 100644 index 0000000000..b42c971788 Binary files /dev/null and b/Jetcaster/debug_2.keystore differ diff --git a/Jetcaster/docs/screenshots.png b/Jetcaster/docs/screenshots.png new file mode 100644 index 0000000000..83c48db44d Binary files /dev/null and b/Jetcaster/docs/screenshots.png differ diff --git a/Jetcaster/docs/tabletop.png b/Jetcaster/docs/tabletop.png new file mode 100644 index 0000000000..d3863658fc Binary files /dev/null and b/Jetcaster/docs/tabletop.png differ diff --git a/Jetcaster/glancewidget/.gitignore b/Jetcaster/glancewidget/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/glancewidget/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/glancewidget/build.gradle.kts b/Jetcaster/glancewidget/build.gradle.kts new file mode 100644 index 0000000000..62f3f9ce10 --- /dev/null +++ b/Jetcaster/glancewidget/build.gradle.kts @@ -0,0 +1,94 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.compose) +} + +android { + namespace = "com.example.jetcaster.glancewidget" + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + + defaultConfig { + minSdk = + libs.versions.minSdk + .get() + .toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + buildFeatures { + compose = true + buildConfig = true + } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.material3) + implementation(libs.androidx.glance) + + implementation(libs.coil.kt.compose) + + implementation(libs.androidx.core.ktx) + implementation(libs.android.material3) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(projects.core.designsystem) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/Jetcaster/glancewidget/proguard-rules.pro b/Jetcaster/glancewidget/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/glancewidget/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/AndroidManifest.xml b/Jetcaster/glancewidget/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..993c1df215 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/Colors.kt b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/Colors.kt new file mode 100644 index 0000000000..8aa3901790 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/Colors.kt @@ -0,0 +1,172 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.glancewidget + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import com.example.jetcaster.designsystem.theme.backgroundDark +import com.example.jetcaster.designsystem.theme.backgroundLight +import com.example.jetcaster.designsystem.theme.errorContainerDark +import com.example.jetcaster.designsystem.theme.errorContainerLight +import com.example.jetcaster.designsystem.theme.errorDark +import com.example.jetcaster.designsystem.theme.errorLight +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight +import com.example.jetcaster.designsystem.theme.inversePrimaryDark +import com.example.jetcaster.designsystem.theme.inversePrimaryLight +import com.example.jetcaster.designsystem.theme.inverseSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseSurfaceLight +import com.example.jetcaster.designsystem.theme.onBackgroundDark +import com.example.jetcaster.designsystem.theme.onBackgroundLight +import com.example.jetcaster.designsystem.theme.onErrorContainerDark +import com.example.jetcaster.designsystem.theme.onErrorContainerLight +import com.example.jetcaster.designsystem.theme.onErrorDark +import com.example.jetcaster.designsystem.theme.onErrorLight +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight +import com.example.jetcaster.designsystem.theme.onPrimaryDark +import com.example.jetcaster.designsystem.theme.onPrimaryLight +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight +import com.example.jetcaster.designsystem.theme.onSecondaryDark +import com.example.jetcaster.designsystem.theme.onSecondaryLight +import com.example.jetcaster.designsystem.theme.onSurfaceDark +import com.example.jetcaster.designsystem.theme.onSurfaceLight +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight +import com.example.jetcaster.designsystem.theme.onTertiaryDark +import com.example.jetcaster.designsystem.theme.onTertiaryLight +import com.example.jetcaster.designsystem.theme.outlineDark +import com.example.jetcaster.designsystem.theme.outlineLight +import com.example.jetcaster.designsystem.theme.outlineVariantDark +import com.example.jetcaster.designsystem.theme.outlineVariantLight +import com.example.jetcaster.designsystem.theme.primaryContainerDark +import com.example.jetcaster.designsystem.theme.primaryContainerLight +import com.example.jetcaster.designsystem.theme.primaryDark +import com.example.jetcaster.designsystem.theme.primaryLight +import com.example.jetcaster.designsystem.theme.scrimDark +import com.example.jetcaster.designsystem.theme.scrimLight +import com.example.jetcaster.designsystem.theme.secondaryContainerDark +import com.example.jetcaster.designsystem.theme.secondaryContainerLight +import com.example.jetcaster.designsystem.theme.secondaryDark +import com.example.jetcaster.designsystem.theme.secondaryLight +import com.example.jetcaster.designsystem.theme.surfaceBrightDark +import com.example.jetcaster.designsystem.theme.surfaceBrightLight +import com.example.jetcaster.designsystem.theme.surfaceContainerDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLight +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLight +import com.example.jetcaster.designsystem.theme.surfaceDark +import com.example.jetcaster.designsystem.theme.surfaceDimDark +import com.example.jetcaster.designsystem.theme.surfaceDimLight +import com.example.jetcaster.designsystem.theme.surfaceLight +import com.example.jetcaster.designsystem.theme.surfaceVariantDark +import com.example.jetcaster.designsystem.theme.surfaceVariantLight +import com.example.jetcaster.designsystem.theme.tertiaryContainerDark +import com.example.jetcaster.designsystem.theme.tertiaryContainerLight +import com.example.jetcaster.designsystem.theme.tertiaryDark +import com.example.jetcaster.designsystem.theme.tertiaryLight + +/** + * Todo, this is copied from the core module. Refactor colors out of that so we can reference them. + */ +private val lightJetcasterColors = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +/** + * Todo, this is copied from the core module. Refactor colors out of that so we can reference them. + */ +internal val DarkJetcasterColors = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) diff --git a/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidget.kt b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidget.kt new file mode 100644 index 0000000000..fc148ec0aa --- /dev/null +++ b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidget.kt @@ -0,0 +1,300 @@ +/* + * Copyright 2024-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.glancewidget + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.SquareIconButton +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import coil.ImageLoader +import coil.request.ErrorResult +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal val TAG = "JetcasterAppWidget" + +/** + * Implementation of App Widget functionality. + */ +class JetcasterAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget + get() = JetcasterAppWidget() +} + +data class JetcasterAppWidgetViewState(val episodeTitle: String, val podcastTitle: String, val isPlaying: Boolean, val albumArtUri: String) + +private object Sizes { + val short = 72.dp + val minWidth = 140.dp + val smallBucketCutoffWidth = 250.dp // anything from minWidth to this will have no title + + val normal = 80.dp + val medium = 56.dp + val condensed = 48.dp +} + +private enum class SizeBucket { Invalid, Narrow, Normal, NarrowShort, NormalShort } + +@Composable +private fun calculateSizeBucket(): SizeBucket { + val size: DpSize = LocalSize.current + val width = size.width + val height = size.height + + return when { + width < Sizes.minWidth -> SizeBucket.Invalid + + width <= Sizes.smallBucketCutoffWidth -> + if (height >= Sizes.short) SizeBucket.Narrow else SizeBucket.NarrowShort + + else -> + if (height >= Sizes.short) SizeBucket.Normal else SizeBucket.NormalShort + } +} + +class JetcasterAppWidget : GlanceAppWidget() { + override val sizeMode: SizeMode + get() = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val testState = JetcasterAppWidgetViewState( + episodeTitle = + "100 - Android 15 DP 1, Stable Studio Iguana, Cloud Photo Picker, and more!", + podcastTitle = "Now in Android", + isPlaying = false, + albumArtUri = "https://static.libsyn.com/p/assets/9/f/f/3/" + + "9ff3cb5dc6cfb3e2e5bbc093207a2619/NIA000_PodcastThumbnail.png", + ) + + provideContent { + val sizeBucket = calculateSizeBucket() + val playPauseIcon = if (testState.isPlaying) PlayPauseIcon.Pause else PlayPauseIcon.Play + val artUri = Uri.parse(testState.albumArtUri) + + GlanceTheme { + when (sizeBucket) { + SizeBucket.Invalid -> WidgetUiInvalidSize() + + SizeBucket.Narrow -> Widget( + iconSize = Sizes.medium, + imageUri = artUri, + playPauseIcon = playPauseIcon, + ) + + SizeBucket.Normal -> WidgetUiNormal( + iconSize = Sizes.normal, + title = testState.episodeTitle, + subtitle = testState.podcastTitle, + imageUri = artUri, + playPauseIcon = playPauseIcon, + ) + + SizeBucket.NarrowShort -> Widget( + iconSize = Sizes.condensed, + imageUri = artUri, + playPauseIcon = playPauseIcon, + ) + + SizeBucket.NormalShort -> WidgetUiNormal( + iconSize = Sizes.condensed, + title = testState.episodeTitle, + subtitle = testState.podcastTitle, + imageUri = artUri, + playPauseIcon = playPauseIcon, + ) + } + } + } + } +} + +@Composable +private fun WidgetUiNormal(title: String, subtitle: String, imageUri: Uri, playPauseIcon: PlayPauseIcon, iconSize: Dp) { + Scaffold { + Row( + GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.Vertical.CenterVertically, + ) { + AlbumArt(imageUri, GlanceModifier.size(iconSize)) + PodcastText(title, subtitle, modifier = GlanceModifier.padding(16.dp).defaultWeight()) + PlayPauseButton(GlanceModifier.size(iconSize), playPauseIcon, {}) + } + } +} + +@Composable +private fun Widget(iconSize: Dp, imageUri: Uri, playPauseIcon: PlayPauseIcon) { + /* title bar will be optional in scaffold in glance 1.1.0-beta3*/ + Scaffold(titleBar = {}) { + Row( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.Vertical.CenterVertically, + ) { + AlbumArt(imageUri, GlanceModifier.size(iconSize)) + Spacer(GlanceModifier.defaultWeight()) + PlayPauseButton(GlanceModifier.size(iconSize), playPauseIcon, {}) + } + } +} + +@Composable +private fun WidgetUiInvalidSize() { + Box(modifier = GlanceModifier.fillMaxSize().background(ColorProvider(Color.Magenta))) { + Text("invalid size") + } +} + +@Composable +private fun AlbumArt(imageUri: Uri, modifier: GlanceModifier = GlanceModifier) { + WidgetAsyncImage(uri = imageUri, contentDescription = null, modifier = modifier) +} + +@Composable +fun PodcastText(title: String, subtitle: String, modifier: GlanceModifier = GlanceModifier) { + val fgColor = GlanceTheme.colors.onPrimaryContainer + val size = LocalSize.current + when { + size.height >= Sizes.short -> Column(modifier) { + Text( + text = title, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = fgColor, + ), + maxLines = 2, + ) + Text( + text = subtitle, + style = TextStyle(fontSize = 14.sp, color = fgColor), + maxLines = 2, + ) + } + + else -> Column(modifier) { + Text( + text = title, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = fgColor, + ), + maxLines = 1, + ) + } + } +} + +@Composable +private fun PlayPauseButton(modifier: GlanceModifier = GlanceModifier.size(Sizes.normal), state: PlayPauseIcon, onClick: () -> Unit) { + val (iconRes: Int, description: Int) = when (state) { + PlayPauseIcon.Play -> R.drawable.outline_play_arrow_24 to R.string.content_description_play + PlayPauseIcon.Pause -> R.drawable.outline_pause_24 to R.string.content_description_pause + } + + val provider = ImageProvider(iconRes) + val contentDescription = LocalContext.current.getString(description) + + SquareIconButton( + modifier = modifier, + imageProvider = provider, + contentDescription = contentDescription, + onClick = onClick, + ) +} + +enum class PlayPauseIcon { Play, Pause } + +/** + * Uses Coil to load images. + */ +@Composable +private fun WidgetAsyncImage(uri: Uri, contentDescription: String?, modifier: GlanceModifier = GlanceModifier) { + var bitmap by remember { mutableStateOf(null) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LaunchedEffect(key1 = uri) { + val request = ImageRequest.Builder(context) + .data(uri) + .size(200, 200) + .target { data: Drawable -> + bitmap = (data as BitmapDrawable).bitmap + } + .build() + + scope.launch(Dispatchers.IO) { + val result = ImageLoader(context).execute(request) + if (result is ErrorResult) { + val t = result.throwable + Log.e(TAG, "Image request error:", t) + } + } + } + + bitmap?.let { bitmap -> + Image( + provider = ImageProvider(bitmap), + contentDescription = contentDescription, + contentScale = ContentScale.FillBounds, + modifier = modifier.cornerRadius(12.dp), // TODO: confirm radius with design + ) + } +} diff --git a/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidgetPreview.kt b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidgetPreview.kt new file mode 100644 index 0000000000..186e4495b7 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidgetPreview.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2024-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.glancewidget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProviderInfo +import android.content.ComponentName +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.SquareIconButton +import androidx.glance.appwidget.compose +import androidx.glance.appwidget.provideContent +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.size +import androidx.glance.layout.wrapContentSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +private object SizesPreview { + val medium = 56.dp +} + +/** + * This is a convenience function for updating the widget preview using Generated Previews. + * + * In a real application, this would be called whenever the widget's state changes. + */ +fun updateWidgetPreview(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + CoroutineScope(Dispatchers.IO).launch { + try { + val appwidgetManager = AppWidgetManager.getInstance(context) + + appwidgetManager.setWidgetPreview( + ComponentName(context, JetcasterAppWidgetReceiver::class.java), + AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN, + JetcasterAppWidgetPreview().compose( + context, + size = DpSize(160.dp, 64.dp), + ), + ) + } catch (e: Exception) { + Log.e(TAG, e.message, e) + } + } + } +} + +class JetcasterAppWidgetPreview : GlanceAppWidget() { + override val sizeMode: SizeMode + get() = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + Widget() + } + } + } +} + +@Composable +private fun Widget() { + Scaffold { + Row( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.Vertical.CenterVertically, + ) { + Image( + modifier = GlanceModifier.wrapContentSize().size(SizesPreview.medium), + provider = ImageProvider(R.drawable.widget_preview_thumbnail), + contentDescription = "", + ) + Spacer(GlanceModifier.defaultWeight()) + SquareIconButton( + modifier = GlanceModifier.size(SizesPreview.medium), + imageProvider = ImageProvider(R.drawable.outline_play_arrow_24), + contentDescription = "", + onClick = { }, + ) + } + } +} diff --git a/Jetcaster/glancewidget/src/main/res/drawable/outline_pause_24.xml b/Jetcaster/glancewidget/src/main/res/drawable/outline_pause_24.xml new file mode 100644 index 0000000000..9b16bde427 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/drawable/outline_pause_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetcaster/glancewidget/src/main/res/drawable/outline_play_arrow_24.xml b/Jetcaster/glancewidget/src/main/res/drawable/outline_play_arrow_24.xml new file mode 100644 index 0000000000..3588d6f062 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/drawable/outline_play_arrow_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetcaster/glancewidget/src/main/res/drawable/outline_skip_next_24.xml b/Jetcaster/glancewidget/src/main/res/drawable/outline_skip_next_24.xml new file mode 100644 index 0000000000..a5b6207c91 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/drawable/outline_skip_next_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetcaster/glancewidget/src/main/res/drawable/widget_preview.png b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview.png new file mode 100644 index 0000000000..eacc0ff4d7 Binary files /dev/null and b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview.png differ diff --git a/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_image_shape.xml b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_image_shape.xml new file mode 100644 index 0000000000..24dcb456d9 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_image_shape.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_thumbnail.png b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_thumbnail.png new file mode 100644 index 0000000000..0a33505bc8 Binary files /dev/null and b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_thumbnail.png differ diff --git a/Jetcaster/glancewidget/src/main/res/layout/widget_preview.xml b/Jetcaster/glancewidget/src/main/res/layout/widget_preview.xml new file mode 100644 index 0000000000..8053af954f --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/layout/widget_preview.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/res/values-h48dp/sizes.xml b/Jetcaster/glancewidget/src/main/res/values-h48dp/sizes.xml new file mode 100644 index 0000000000..f9321a0331 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-h48dp/sizes.xml @@ -0,0 +1,4 @@ + + + 58dp + \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/res/values-night-v31/colors.xml b/Jetcaster/glancewidget/src/main/res/values-night-v31/colors.xml new file mode 100644 index 0000000000..8348815e90 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-night-v31/colors.xml @@ -0,0 +1,19 @@ + + + + @android:color/system_accent2_800 + diff --git a/Jetcaster/glancewidget/src/main/res/values-night/colors.xml b/Jetcaster/glancewidget/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..3e00c7293e --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-night/colors.xml @@ -0,0 +1,19 @@ + + + + #ff20333d + diff --git a/Jetcaster/glancewidget/src/main/res/values-v31/colors.xml b/Jetcaster/glancewidget/src/main/res/values-v31/colors.xml new file mode 100644 index 0000000000..b9536a2ec5 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-v31/colors.xml @@ -0,0 +1,19 @@ + + + + @android:color/system_accent2_50 + diff --git a/Jetcaster/glancewidget/src/main/res/values-v31/styles.xml b/Jetcaster/glancewidget/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000000..7f5c58270b --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/Jetcaster/glancewidget/src/main/res/values/colors.xml b/Jetcaster/glancewidget/src/main/res/values/colors.xml new file mode 100644 index 0000000000..b545d6af07 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values/colors.xml @@ -0,0 +1,23 @@ + + + + + #FFECDCFF + #FF7CD7BA + #FF2C322F + #ffe0f3ff + diff --git a/Jetcaster/glancewidget/src/main/res/values/sizes.xml b/Jetcaster/glancewidget/src/main/res/values/sizes.xml new file mode 100644 index 0000000000..0cda911ac9 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values/sizes.xml @@ -0,0 +1,4 @@ + + + 80dp + \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/res/values/strings.xml b/Jetcaster/glancewidget/src/main/res/values/strings.xml new file mode 100644 index 0000000000..8248fa8d0d --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Play your podcasts + Play + Pause + \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/res/values/styles.xml b/Jetcaster/glancewidget/src/main/res/values/styles.xml new file mode 100644 index 0000000000..eb4c694eed --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/Jetcaster/glancewidget/src/main/res/xml/jetcaster_info.xml b/Jetcaster/glancewidget/src/main/res/xml/jetcaster_info.xml new file mode 100644 index 0000000000..6391582b2d --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/xml/jetcaster_info.xml @@ -0,0 +1,17 @@ + + \ No newline at end of file diff --git a/Jetcaster/gradle.properties b/Jetcaster/gradle.properties index 18038533b3..646f68d67b 100644 --- a/Jetcaster/gradle.properties +++ b/Jetcaster/gradle.properties @@ -37,6 +37,3 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml new file mode 100644 index 0000000000..8b53153689 --- /dev/null +++ b/Jetcaster/gradle/libs.versions.toml @@ -0,0 +1,188 @@ +##### +# This file is duplicated to individual samples from the global scripts/libs.versions.toml +# Do not add a dependency to an individual sample, edit the global version instead. +##### +[versions] +accompanist = "0.37.3" +android-material3 = "1.14.0-rc01" +androidGradlePlugin = "9.2.1" +androidx-activity-compose = "1.13.0" +androidx-appcompat = "1.7.1" +androidx-compose-bom = "2026.05.00" +androidx-constraintlayout = "1.1.1" +androidx-core-splashscreen = "1.2.0" +androidx-corektx = "1.18.0" +androidx-glance = "1.1.1" +androidx-lifecycle = "2.8.2" +androidx-lifecycle-compose = "2.10.0" +androidx-lifecycle-runtime-compose = "2.10.0" +androidx-material3 = "1.5.0-alpha19" +androidx-navigation = "2.9.8" +androidx-palette = "1.0.0" +androidx-test = "1.7.0" +androidx-test-espresso = "3.7.0" +androidx-test-ext-junit = "1.3.0" +androidx-test-ext-truth = "1.7.0" +androidx-tv-foundation = "1.0.0" +androidx-tv-material = "1.1.0" +androidx-wear-compose-material3 = "1.6.1" +androidx-wear-compose = "1.6.1" +androidx-window = "1.5.1" +androidxHiltNavigationCompose = "1.3.0" +androix-test-uiautomator = "2.3.0" +coil = "2.7.0" +# @keep +compileSdk = "37" +coroutines = "1.11.0" +google-maps = "20.0.0" +gradle-versions = "0.54.0" +hilt = "2.59.2" +hiltExt = "1.3.0" +horologist = "0.7.15" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.3.21" +kotlinx-serialization-json = "1.11.0" +kotlinx_immutable = "0.4.0" +ksp = "2.3.7" +maps-compose = "8.3.0" +media3 = "1.10.0" +# @keep +minSdk = "23" +okhttp = "5.3.2" +play-services-wearable = "19.0.0" +robolectric = "4.16.1" +roborazzi = "1.60.0" +rome = "2.1.0" +room = "2.8.4" +secrets = "2.0.1" +spotless = "8.4.0" +# @keep +targetSdk = "33" +version-catalog-update = "1.1.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "media3" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = "androidx.test:runner:1.6.2" +androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androix-test-uiautomator" } +androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" } +androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" } +androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" } +androidx-wear-compose-material = { module = "androidx.wear.compose:compose-material3", version.ref = "androidx-wear-compose-material3" } +androidx-wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "androidx-wear-compose" } +androidx-wear-compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "androidx-wear-compose" } +androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } +androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" } +coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdkDesugar" } +dagger-hiltandroidplugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } +googlemaps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" } +googlemaps-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "google-maps" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltExt" } +horologist-audio-ui = { module = "com.google.android.horologist:horologist-audio-ui", version.ref = "horologist" } +horologist-audio-uimaterial3 = { module = "com.google.android.horologist:horologist-audio-ui-material3", version.ref = "horologist" } +horologist-audio-ui-model = { module = "com.google.android.horologist:horologist-audio-ui-model", version.ref = "horologist" } +horologist-composables = { module = "com.google.android.horologist:horologist-composables", version.ref = "horologist" } +horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" } +horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologist" } +horologist-compose-tools = { module = "com.google.android.horologist:horologist-compose-tools", version.ref = "horologist" } +horologist-images-coil = { module = "com.google.android.horologist:horologist-images-coil", version.ref = "horologist" } +horologist-media-data = { module = "com.google.android.horologist:horologist-media-data", version.ref = "horologist" } +horologist-media-ui = { module = "com.google.android.horologist:horologist-media-ui", version.ref = "horologist" } +horologist-media-uimaterial3 = { module = "com.google.android.horologist:horologist-media-ui-material3", version.ref = "horologist" } +horologist-media-ui-model = { module = "com.google.android.horologist:horologist-media-ui-model", version.ref = "horologist" } +horologist-roboscreenshots = { module = "com.google.android.horologist:horologist-roboscreenshots", version.ref = "horologist" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx_immutable" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" } +roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" } +roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" } +rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome" } +rometools-rome = { module = "com.rometools:rome", version.ref = "rome" } +androidx-media3-session = {module = "androidx.media3:media3-session",version.ref = "media3"} +androidx-media3-exoplayer = {module = "androidx.media3:media3-exoplayer", version.ref = "media3"} +androidx-media3-common-ktx = { group = "androidx.media3", name = "media3-common-ktx", version.ref = "media3" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } +android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } +compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } +secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/Jetcaster/gradle/wrapper/gradle-wrapper.jar b/Jetcaster/gradle/wrapper/gradle-wrapper.jar index e708b1c023..7454180f2a 100644 Binary files a/Jetcaster/gradle/wrapper/gradle-wrapper.jar and b/Jetcaster/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Jetcaster/gradle/wrapper/gradle-wrapper.properties b/Jetcaster/gradle/wrapper/gradle-wrapper.properties index 2a563242c1..46cbb0e720 100644 --- a/Jetcaster/gradle/wrapper/gradle-wrapper.properties +++ b/Jetcaster/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,19 @@ +# Copyright 2023 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/Jetcaster/gradlew b/Jetcaster/gradlew index 4f906e0c81..744e882ed5 100755 --- a/Jetcaster/gradlew +++ b/Jetcaster/gradlew @@ -72,7 +72,7 @@ case "`uname`" in Darwin* ) darwin=true ;; - MINGW* ) + MSYS* | MINGW* ) msys=true ;; NONSTOP* ) diff --git a/Jetcaster/mobile/build.gradle.kts b/Jetcaster/mobile/build.gradle.kts new file mode 100644 index 0000000000..7ddf4db5fa --- /dev/null +++ b/Jetcaster/mobile/build.gradle.kts @@ -0,0 +1,158 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) + alias(libs.plugins.compose) +} + +android { + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + namespace = "com.example.jetcaster" + + defaultConfig { + applicationId = "com.example.jetcaster" + minSdk = + libs.versions.minSdk + .get() + .toInt() + targetSdk = + libs.versions.targetSdk + .get() + .toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + // Important: change the keystore for a production deployment + val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore") + val localKeystore = rootProject.file("debug_2.keystore") + val hasKeyInfo = userKeystore.exists() + create("release") { + // get from env variables + storeFile = if (hasKeyInfo) userKeystore else localKeystore + storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password") + keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias") + keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password") + } + } + + buildTypes { + getByName("debug") { + } + + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging.resources { + // The Rome library JARs embed some internal utils libraries in nested JARs. + // We don't need them so we exclude them in the final package. + excludes += "/*.jar" + + // Multiple dependency bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } + + composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + metricsDestination = layout.buildDirectory.dir("compose_compiler") + stabilityConfigurationFiles = listOf(rootProject.layout.projectDirectory.file("stability_config.conf")) + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.collections.immutable) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.palette) + + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Compose + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive) + implementation(libs.androidx.compose.material3.adaptive.layout) + implementation(libs.androidx.compose.material3.adaptive.navigation) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + + implementation(libs.androidx.window) + implementation(libs.androidx.window.core) + + implementation(libs.accompanist.adaptive) + + implementation(libs.coil.kt.compose) + + implementation(projects.core.data) + implementation(projects.core.designsystem) + implementation(projects.core.domain) + implementation(projects.glancewidget) + implementation(projects.core.domainTesting) + + coreLibraryDesugaring(libs.core.jdk.desugaring) +} diff --git a/Jetcaster/mobile/proguard-rules.pro b/Jetcaster/mobile/proguard-rules.pro new file mode 100644 index 0000000000..8bba6b5e9c --- /dev/null +++ b/Jetcaster/mobile/proguard-rules.pro @@ -0,0 +1,52 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses + +# Rome reflectively loads classes referenced in com/rometools/rome/rome.properties. +-adaptresourcefilecontents com/rometools/rome/rome.properties +-keep class * implements com.rometools.rome.feed.synd.Converter +-keep class * implements com.rometools.rome.io.ModuleParser +-keep class * implements com.rometools.rome.io.WireFeedParser + +# Disable warnings for missing classes from OkHttp. +-dontwarn org.conscrypt.ConscryptHostnameVerifier + +# Disable warnings for missing classes from JDOM. +-dontwarn org.jaxen.DefaultNavigator +-dontwarn org.jaxen.NamespaceContext +-dontwarn org.jaxen.VariableContext + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE + +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } \ No newline at end of file diff --git a/Jetcaster/mobile/src/main/AndroidManifest.xml b/Jetcaster/mobile/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..d2f05d870a --- /dev/null +++ b/Jetcaster/mobile/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/JetcasterApplication.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/JetcasterApplication.kt new file mode 100644 index 0000000000..af37480202 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/JetcasterApplication.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster + +import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +/** + * Application which sets up our dependency [Graph] with a context. + */ +@HiltAndroidApp +class JetcasterApplication : + Application(), + ImageLoaderFactory { + + @Inject lateinit var imageLoader: ImageLoader + + override fun newImageLoader(): ImageLoader = imageLoader +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt new file mode 100644 index 0000000000..f67a0295ff --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2020-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalSharedTransitionApi::class) + +package com.example.jetcaster.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.scaleOut +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.res.stringResource +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.window.layout.DisplayFeature +import com.example.jetcaster.R +import com.example.jetcaster.ui.home.MainScreen +import com.example.jetcaster.ui.player.PlayerScreen + +@Composable +@OptIn(ExperimentalSharedTransitionApi::class) +fun JetcasterApp(displayFeatures: List, appState: JetcasterAppState = rememberJetcasterAppState()) { + val adaptiveInfo = currentWindowAdaptiveInfo() + if (appState.isOnline) { + SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this, + ) { + NavHost( + navController = appState.navController, + startDestination = Screen.Home.route, + popExitTransition = { scaleOut(targetScale = 0.9f) }, + popEnterTransition = { EnterTransition.None }, + ) { + composable(Screen.Home.route) { backStackEntry -> + CompositionLocalProvider( + LocalAnimatedVisibilityScope provides this, + ) { + MainScreen( + windowSizeClass = adaptiveInfo.windowSizeClass, + navigateToPlayer = { episode -> + appState.navigateToPlayer(episode.uri, backStackEntry) + }, + ) + } + } + composable(Screen.Player.route) { + CompositionLocalProvider( + LocalAnimatedVisibilityScope provides this, + ) { + PlayerScreen( + windowSizeClass = adaptiveInfo.windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = appState::navigateBack, + ) + } + } + } + } + } + } else { + OfflineDialog { appState.refreshOnline() } + } +} + +@Composable +fun OfflineDialog(onRetry: () -> Unit) { + AlertDialog( + onDismissRequest = {}, + title = { Text(text = stringResource(R.string.connection_error_title)) }, + text = { Text(text = stringResource(R.string.connection_error_message)) }, + confirmButton = { + TextButton(onClick = onRetry) { + Text(stringResource(R.string.retry_label)) + } + }, + ) +} + +val LocalAnimatedVisibilityScope = compositionLocalOf { null } +val LocalSharedTransitionScope = compositionLocalOf { null } diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt new file mode 100644 index 0000000000..5992487c83 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat.getSystemService +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController + +/** + * List of screens for [JetcasterApp] + */ +sealed class Screen(val route: String) { + object Home : Screen("home") + object Player : Screen("player/{$ARG_EPISODE_URI}") { + fun createRoute(episodeUri: String) = "player/$episodeUri" + } + + object PodcastDetails : Screen("podcast/{$ARG_PODCAST_URI}") { + fun createRoute(podcastUri: String) = "podcast/$podcastUri" + } + + companion object { + val ARG_PODCAST_URI = "podcastUri" + val ARG_EPISODE_URI = "episodeUri" + } +} + +@Composable +fun rememberJetcasterAppState(navController: NavHostController = rememberNavController(), context: Context = LocalContext.current) = + remember(navController, context) { + JetcasterAppState(navController, context) + } + +class JetcasterAppState(val navController: NavHostController, private val context: Context) { + var isOnline by mutableStateOf(checkIfOnline()) + private set + + fun refreshOnline() { + isOnline = checkIfOnline() + } + + fun navigateToPlayer(episodeUri: String, from: NavBackStackEntry) { + // In order to discard duplicated navigation events, we check the Lifecycle + if (from.lifecycleIsResumed()) { + val encodedUri = Uri.encode(episodeUri) + navController.navigate(Screen.Player.createRoute(encodedUri)) + } + } + + fun navigateToPodcastDetails(podcastUri: String, from: NavBackStackEntry) { + if (from.lifecycleIsResumed()) { + val encodedUri = Uri.encode(podcastUri) + navController.navigate(Screen.PodcastDetails.createRoute(encodedUri)) + } + } + + fun navigateBack() { + navController.popBackStack() + } + + @Suppress("DEPRECATION") + private fun checkIfOnline(): Boolean { + val cm = getSystemService(context, ConnectivityManager::class.java) + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val capabilities = cm?.getNetworkCapabilities(cm.activeNetwork) ?: return false + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + cm?.activeNetworkInfo?.isConnectedOrConnecting == true + } + } +} + +/** + * If the lifecycle is not resumed it means this NavBackStackEntry already processed a nav event. + * + * This is used to de-duplicate navigation events. + */ +private fun NavBackStackEntry.lifecycleIsResumed() = this.lifecycle.currentState == Lifecycle.State.RESUMED diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/MainActivity.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/MainActivity.kt new file mode 100644 index 0000000000..d7cfca1a73 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/MainActivity.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2020-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.example.jetcaster.glancewidget.updateWidgetPreview +import com.example.jetcaster.ui.theme.JetcasterTheme +import com.google.accompanist.adaptive.calculateDisplayFeatures +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + updateWidgetPreview(this) + setContent { + val displayFeatures = calculateDisplayFeatures(this) + + JetcasterTheme { + JetcasterApp( + displayFeatures, + ) + } + } + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt new file mode 100644 index 0000000000..7cfcded262 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -0,0 +1,790 @@ +/* + * Copyright 2020-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.home + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingToolbarColors +import androidx.compose.material3.HorizontalFloatingToolbar +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.Posture +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.material3.adaptive.allVerticalHingeBounds +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.HingePolicy +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator +import androidx.compose.material3.adaptive.occludingVerticalHingeBounds +import androidx.compose.material3.adaptive.separatingVerticalHingeBounds +import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowSizeClass +import com.example.jetcaster.R +import com.example.jetcaster.core.domain.testing.PreviewCategories +import com.example.jetcaster.core.domain.testing.PreviewPodcastEpisodes +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.LibraryInfo +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.ui.home.discover.discoverItems +import com.example.jetcaster.ui.home.library.libraryItems +import com.example.jetcaster.ui.podcast.PodcastDetailsScreen +import com.example.jetcaster.ui.podcast.PodcastDetailsViewModel +import com.example.jetcaster.ui.theme.JetcasterTheme +import com.example.jetcaster.ui.tooling.DevicePreviews +import com.example.jetcaster.util.ToggleFollowPodcastIconButton +import com.example.jetcaster.util.fullWidthItem +import com.example.jetcaster.util.isCompact +import com.example.jetcaster.util.quantityStringResource +import com.example.jetcaster.util.radialGradientScrim +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun ThreePaneScaffoldNavigator.isMainPaneHidden(): Boolean = + scaffoldValue[SupportingPaneScaffoldRole.Main] == PaneAdaptedValue.Hidden + +/** + * Copied from `calculatePaneScaffoldDirective()` in [PaneScaffoldDirective], with modifications to + * only show 1 pane horizontally if either width or height size class is compact. + */ +fun calculateScaffoldDirective( + windowAdaptiveInfo: WindowAdaptiveInfo, + verticalHingePolicy: HingePolicy = HingePolicy.AvoidSeparating, +): PaneScaffoldDirective { + val maxHorizontalPartitions: Int + val verticalSpacerSize: Dp + if (windowAdaptiveInfo.windowSizeClass.isCompact) { + // Window width or height is compact. Limit to 1 pane horizontally. + maxHorizontalPartitions = 1 + verticalSpacerSize = 0.dp + } else { + if (windowAdaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) { + maxHorizontalPartitions = 2 + verticalSpacerSize = 24.dp + } else { + maxHorizontalPartitions = 1 + verticalSpacerSize = 0.dp + } + } + val maxVerticalPartitions: Int + val horizontalSpacerSize: Dp + + if (windowAdaptiveInfo.windowPosture.isTabletop) { + maxVerticalPartitions = 2 + horizontalSpacerSize = 24.dp + } else { + maxVerticalPartitions = 1 + horizontalSpacerSize = 0.dp + } + + val defaultPanePreferredWidth = 360.dp + + return PaneScaffoldDirective( + maxHorizontalPartitions, + verticalSpacerSize, + maxVerticalPartitions, + horizontalSpacerSize, + defaultPanePreferredWidth, + getExcludedVerticalBounds(windowAdaptiveInfo.windowPosture, verticalHingePolicy), + ) +} + +/** + * Copied from `getExcludedVerticalBounds()` in [PaneScaffoldDirective] since it is private. + */ +private fun getExcludedVerticalBounds(posture: Posture, hingePolicy: HingePolicy): List = when (hingePolicy) { + HingePolicy.AvoidSeparating -> posture.separatingVerticalHingeBounds + HingePolicy.AvoidOccluding -> posture.occludingVerticalHingeBounds + HingePolicy.AlwaysAvoid -> posture.allVerticalHingeBounds + else -> emptyList() +} + +@Composable +fun MainScreen(windowSizeClass: WindowSizeClass, navigateToPlayer: (EpisodeInfo) -> Unit, viewModel: HomeViewModel = hiltViewModel()) { + val homeScreenUiState by viewModel.state.collectAsStateWithLifecycle() + val uiState = homeScreenUiState + Box { + HomeScreenReady( + uiState = uiState, + windowSizeClass = windowSizeClass, + navigateToPlayer = navigateToPlayer, + viewModel = viewModel, + ) + + if (uiState.errorMessage != null) { + HomeScreenError(onRetry = viewModel::refresh) + } + } +} + +@Composable +private fun HomeScreenError(onRetry: () -> Unit, modifier: Modifier = Modifier) { + Surface(modifier = modifier) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + Text( + text = stringResource(id = R.string.an_error_has_occurred), + modifier = Modifier.padding(16.dp), + ) + Button(onClick = onRetry) { + Text(text = stringResource(id = R.string.retry_label)) + } + } + } +} + +@Preview +@Composable +fun HomeScreenErrorPreview() { + JetcasterTheme { + HomeScreenError(onRetry = {}) + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun HomeScreenReady( + uiState: HomeScreenUiState, + windowSizeClass: WindowSizeClass, + navigateToPlayer: (EpisodeInfo) -> Unit, + viewModel: HomeViewModel = hiltViewModel(), +) { + val navigator = rememberSupportingPaneScaffoldNavigator( + scaffoldDirective = calculateScaffoldDirective(currentWindowAdaptiveInfo()), + ) + val scope = rememberCoroutineScope() + + BackHandler(enabled = navigator.canNavigateBack()) { + scope.launch { + navigator.navigateBack() + } + } + + Surface { + SupportingPaneScaffold( + value = navigator.scaffoldValue, + directive = navigator.scaffoldDirective, + mainPane = { + HomeScreen( + isHomeAppBarExpanded = windowSizeClass.isCompact, + isLoading = uiState.isLoading, + featuredPodcasts = uiState.featuredPodcasts, + homeCategories = uiState.homeCategories, + selectedHomeCategory = uiState.selectedHomeCategory, + filterableCategoriesModel = uiState.filterableCategoriesModel, + podcastCategoryFilterResult = uiState.podcastCategoryFilterResult, + library = uiState.library, + onHomeAction = viewModel::onHomeAction, + navigateToPodcastDetails = { + scope.launch { + navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, it.uri) + } + }, + navigateToPlayer = navigateToPlayer, + modifier = Modifier.fillMaxSize(), + ) + }, + supportingPane = { + val podcastUri = navigator.currentDestination?.contentKey + if (!podcastUri.isNullOrEmpty()) { + val podcastDetailsViewModel = + hiltViewModel( + key = podcastUri, + ) { + it.create(podcastUri) + } + PodcastDetailsScreen( + viewModel = podcastDetailsViewModel, + navigateToPlayer = navigateToPlayer, + navigateBack = { + if (navigator.canNavigateBack()) { + scope.launch { + navigator.navigateBack() + } + } + }, + showBackButton = navigator.isMainPaneHidden(), + ) + } + }, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeAppBar(isExpanded: Boolean, modifier: Modifier = Modifier) { + var queryText by remember { + mutableStateOf("") + } + Row( + horizontalArrangement = Arrangement.End, + modifier = modifier + .fillMaxWidth() + .background(Color.Transparent) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = queryText, + onQueryChange = { queryText = it }, + onSearch = {}, + expanded = false, + onExpandedChange = {}, + enabled = true, + placeholder = { + Text(stringResource(id = R.string.search_for_a_podcast)) + }, + leadingIcon = { + Icon( + painterResource(id = R.drawable.ic_search), + contentDescription = null, + ) + }, + trailingIcon = { + Icon( + painterResource(id = R.drawable.ic_account_circle), + contentDescription = stringResource(R.string.cd_account), + ) + }, + interactionSource = null, + modifier = if (isExpanded) Modifier.fillMaxWidth() else Modifier, + ) + }, + expanded = false, + onExpandedChange = {}, + ) {} + } +} + +@Composable +private fun HomeScreenBackground(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) { + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.background), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .radialGradientScrim(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)), + ) + content() + } +} + +@Composable +private fun HomeScreen( + isHomeAppBarExpanded: Boolean, + isLoading: Boolean, + featuredPodcasts: ImmutableList, + selectedHomeCategory: HomeCategory, + homeCategories: List, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + library: LibraryInfo, + onHomeAction: (HomeAction) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + modifier: Modifier = Modifier, +) { + // Effect that changes the home category selection when there are no subscribed podcasts + LaunchedEffect(key1 = featuredPodcasts) { + if (featuredPodcasts.isEmpty()) { + onHomeAction(HomeAction.HomeCategorySelected(HomeCategory.Discover)) + } + } + + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + HomeScreenBackground( + modifier = modifier.windowInsetsPadding(WindowInsets.navigationBars), + ) { + Scaffold( + topBar = { + Column { + HomeAppBar( + isExpanded = isHomeAppBarExpanded, + modifier = Modifier.fillMaxWidth(), + ) + if (isLoading) { + LinearProgressIndicator( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + containerColor = Color.Transparent, + ) { contentPadding -> + // Main Content + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + val showHomeCategoryTabs = featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty() + HomeContent( + featuredPodcasts = featuredPodcasts, + selectedHomeCategory = selectedHomeCategory, + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + library = library, + modifier = Modifier.padding(contentPadding), + onHomeAction = { action -> + if (action is HomeAction.QueueEpisode) { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + } + onHomeAction(action) + }, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + ) + + if (showHomeCategoryTabs) { + PillToolbar( + selectedHomeCategory, + onHomeAction, + Modifier.align(Alignment.BottomCenter), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun PillToolbar(selectedHomeCategory: HomeCategory, onHomeAction: (HomeAction) -> Unit, modifier: Modifier = Modifier) { + HorizontalFloatingToolbar( + modifier = modifier, + colors = FloatingToolbarColors( + toolbarContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + toolbarContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + fabContainerColor = MaterialTheme.colorScheme.tertiary, + fabContentColor = MaterialTheme.colorScheme.onTertiary, + ), + expanded = true, + content = { + val libraryContainerColor = + if (selectedHomeCategory.name == HomeCategory.Library.name) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + + val libraryContentColor = + if (selectedHomeCategory.name == HomeCategory.Library.name) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Button( + onClick = { onHomeAction(HomeAction.HomeCategorySelected(HomeCategory.Library)) }, + colors = ButtonColors( + containerColor = libraryContainerColor, + contentColor = libraryContentColor, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Row(Modifier) { + Icon( + painterResource(id = R.drawable.ic_library_music), + modifier = Modifier.padding(end = 8.dp), + contentDescription = stringResource( + R.string.library_toolbar_content_description, + ), + ) + Text(stringResource(R.string.library_toolbar)) + } + } + + val discoverContainerColor = + if (selectedHomeCategory.name == HomeCategory.Library.name) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + MaterialTheme.colorScheme.secondary + } + + val discoverContentColor = + if (selectedHomeCategory.name == HomeCategory.Library.name) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + + Button( + onClick = { onHomeAction(HomeAction.HomeCategorySelected(HomeCategory.Discover)) }, + colors = ButtonColors( + containerColor = discoverContainerColor, + contentColor = discoverContentColor, + disabledContainerColor = MaterialTheme.colorScheme.secondary, + disabledContentColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + ) { + Row { + Icon( + painterResource(R.drawable.genres), + modifier = Modifier.padding(end = 8.dp), + contentDescription = stringResource( + R.string.discover_toolbar_content_description, + ), + ) + Text(stringResource(R.string.discover_toolbar)) + } + } + }, + ) +} + +@Composable +private fun HomeContent( + featuredPodcasts: ImmutableList, + selectedHomeCategory: HomeCategory, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + library: LibraryInfo, + modifier: Modifier = Modifier, + onHomeAction: (HomeAction) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, +) { + val pagerState = rememberPagerState { featuredPodcasts.size } + LaunchedEffect(pagerState, featuredPodcasts) { + snapshotFlow { pagerState.currentPage } + .collect { + val podcast = featuredPodcasts.getOrNull(it) + onHomeAction(HomeAction.LibraryPodcastSelected(podcast)) + } + } + + HomeContentGrid( + featuredPodcasts = featuredPodcasts, + selectedHomeCategory = selectedHomeCategory, + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + library = library, + modifier = modifier, + onHomeAction = onHomeAction, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + ) +} + +@Composable +private fun HomeContentGrid( + featuredPodcasts: ImmutableList, + selectedHomeCategory: HomeCategory, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + library: LibraryInfo, + modifier: Modifier = Modifier, + onHomeAction: (HomeAction) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(362.dp), + modifier = modifier.fillMaxSize(), + ) { + when (selectedHomeCategory) { + HomeCategory.Library -> { + if (featuredPodcasts.isNotEmpty()) { + fullWidthItem { + FollowedPodcastItem( + items = featuredPodcasts, + onPodcastUnfollowed = { + onHomeAction(HomeAction.PodcastUnfollowed(it)) + }, + navigateToPodcastDetails = navigateToPodcastDetails, + modifier = Modifier + .fillMaxWidth(), + ) + } + } + + libraryItems( + library = library, + navigateToPlayer = navigateToPlayer, + onQueueEpisode = { onHomeAction(HomeAction.QueueEpisode(it)) }, + removeFromQueue = { onHomeAction(HomeAction.RemoveEpisode(it)) }, + ) + } + + HomeCategory.Discover -> { + discoverItems( + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + onCategorySelected = { onHomeAction(HomeAction.CategorySelected(it)) }, + onTogglePodcastFollowed = { + onHomeAction(HomeAction.TogglePodcastFollowed(it)) + }, + onQueueEpisode = { onHomeAction(HomeAction.QueueEpisode(it)) }, + removeFromQueue = { onHomeAction(HomeAction.RemoveEpisode(it)) }, + ) + } + } + } +} + +@Composable +private fun FollowedPodcastItem( + items: ImmutableList, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Spacer(Modifier.height(16.dp)) + + FollowedPodcasts( + items = items, + onPodcastUnfollowed = onPodcastUnfollowed, + navigateToPodcastDetails = navigateToPodcastDetails, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(16.dp)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FollowedPodcasts( + items: ImmutableList, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, +) { + // TODO: Using BoxWithConstraints is not quite performant since it requires 2 passes to compute + // the content padding. This should be revisited once a carousel component is available. + // Alternatively, version 1.7.0-alpha05 of Compose Foundation supports `snapPosition` + // which solves this problem and avoids this calculation altogether. Once 1.7.0 is + // stable, this implementation can be updated. + BoxWithConstraints( + modifier = modifier.background(Color.Transparent), + ) { + val horizontalPadding = this.maxWidth + HorizontalMultiBrowseCarousel( + state = rememberCarouselState { items.count() }, + preferredItemWidth = 205.dp, + itemSpacing = 12.dp, + contentPadding = PaddingValues(8.dp), + ) { page -> + val podcast = items[page] + FollowedPodcastCarouselItem( + podcastImageUrl = podcast.imageUrl, + podcastTitle = podcast.title, + onUnfollowedClick = { onPodcastUnfollowed(podcast) }, + lastEpisodeDateText = podcast.lastEpisodeDate?.let { lastUpdated(it) }, + modifier = Modifier + .fillMaxSize() + .maskClip(MaterialTheme.shapes.large) + .clickable { + navigateToPodcastDetails(podcast) + }, + ) + } + } +} + +@Composable +private fun FollowedPodcastCarouselItem( + podcastTitle: String, + podcastImageUrl: String, + modifier: Modifier = Modifier, + lastEpisodeDateText: String? = null, + onUnfollowedClick: () -> Unit, +) { + val gradient = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) + + Box( + modifier + .height(230.dp), + ) { + PodcastImage( + podcastImageUrl = podcastImageUrl, + contentDescription = podcastTitle, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium), + ) + ToggleFollowPodcastIconButton( + onClick = onUnfollowedClick, + isFollowed = true, /* All podcasts are followed in this feed */ + modifier = Modifier.align(Alignment.TopStart), + ) + Box(modifier = Modifier.matchParentSize().background(gradient)) + if (lastEpisodeDateText != null) { + Text( + text = lastEpisodeDateText, + style = MaterialTheme.typography.bodySmall, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(12.dp) + .align(Alignment.BottomStart), + ) + } + } +} + +@Composable +private fun lastUpdated(updated: OffsetDateTime): String { + val duration = Duration.between(updated.toLocalDateTime(), LocalDateTime.now()) + val days = duration.toDays().toInt() + + return when { + days > 28 -> stringResource(R.string.updated_longer) + + days >= 7 -> { + val weeks = days / 7 + quantityStringResource(R.plurals.updated_weeks_ago, weeks, weeks) + } + + days > 0 -> quantityStringResource(R.plurals.updated_days_ago, days, days) + + else -> stringResource(R.string.updated_today) + } +} + +@Preview +@Composable +private fun HomeAppBarPreview() { + JetcasterTheme { + HomeAppBar( + isExpanded = false, + ) + } +} + +@DevicePreviews +@Composable +private fun PreviewHome() { + JetcasterTheme { + HomeScreen( + isHomeAppBarExpanded = true, + isLoading = true, + featuredPodcasts = PreviewPodcasts.toImmutableList(), + homeCategories = HomeCategory.entries, + selectedHomeCategory = HomeCategory.Discover, + filterableCategoriesModel = FilterableCategoriesModel( + categories = PreviewCategories, + selectedCategory = PreviewCategories.firstOrNull(), + ), + podcastCategoryFilterResult = PodcastCategoryFilterResult( + topPodcasts = PreviewPodcasts, + episodes = PreviewPodcastEpisodes, + ), + library = LibraryInfo(), + onHomeAction = {}, + navigateToPodcastDetails = {}, + navigateToPlayer = {}, + ) + } +} + +@Composable +@Preview +private fun PreviewPodcastCard() { + JetcasterTheme { + FollowedPodcastCarouselItem( + modifier = Modifier.size(128.dp), + podcastTitle = "", + podcastImageUrl = "", + onUnfollowedClick = {}, + ) + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt new file mode 100644 index 0000000000..924b97792a --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -0,0 +1,238 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.home + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.domain.FilterableCategoriesUseCase +import com.example.jetcaster.core.domain.PodcastCategoryFilterUseCase +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.LibraryInfo +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.model.asDaoModel +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.model.asPodcastToEpisodeInfo +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class HomeViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val podcastStore: PodcastStore, + private val episodeStore: EpisodeStore, + private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase, + private val filterableCategoriesUseCase: FilterableCategoriesUseCase, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + // Holds our currently selected podcast in the library + private val selectedLibraryPodcast = MutableStateFlow(null) + + // Holds our currently selected home category + private val selectedHomeCategory = MutableStateFlow(HomeCategory.Discover) + + // Holds the currently available home categories + private val homeCategories = MutableStateFlow(HomeCategory.entries) + + // Holds our currently selected category + private val _selectedCategory = MutableStateFlow(null) + + // Holds our view state which the UI collects via [state] + private val _state = MutableStateFlow(HomeScreenUiState()) + + // Holds the view state if the UI is refreshing for new data + private val refreshing = MutableStateFlow(false) + + private val subscribedPodcasts = podcastStore.followedPodcastsSortedByLastEpisode(limit = 10) + .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) + + val state: StateFlow + get() = _state + + init { + viewModelScope.launch { + // Combines the latest value from each of the flows, allowing us to generate a + // view state instance which only contains the latest values. + com.example.jetcaster.core.util.combine( + homeCategories, + selectedHomeCategory, + subscribedPodcasts, + refreshing, + _selectedCategory.flatMapLatest { selectedCategory -> + filterableCategoriesUseCase(selectedCategory) + }, + _selectedCategory.flatMapLatest { + podcastCategoryFilterUseCase(it) + }, + subscribedPodcasts.flatMapLatest { podcasts -> + episodeStore.episodesInPodcasts( + podcastUris = podcasts.map { it.podcast.uri }, + limit = 20, + ) + }, + ) { + homeCategories, + homeCategory, + podcasts, + refreshing, + filterableCategories, + podcastCategoryFilterResult, + libraryEpisodes, + -> + + _selectedCategory.value = filterableCategories.selectedCategory + + // Override selected home category to show 'DISCOVER' if there are no + // featured podcasts + selectedHomeCategory.value = + if (podcasts.isEmpty()) HomeCategory.Discover else homeCategory + + HomeScreenUiState( + isLoading = refreshing, + homeCategories = homeCategories, + selectedHomeCategory = homeCategory, + featuredPodcasts = podcasts.map { it.asExternalModel() }.toPersistentList(), + filterableCategoriesModel = filterableCategories, + podcastCategoryFilterResult = podcastCategoryFilterResult, + library = libraryEpisodes.asLibrary(), + ) + }.catch { throwable -> + emit( + HomeScreenUiState( + isLoading = false, + errorMessage = throwable.message, + ), + ) + }.collect { + _state.value = it + } + } + + refresh(force = false) + } + + fun refresh(force: Boolean = true) { + viewModelScope.launch { + runCatching { + refreshing.value = true + podcastsRepository.updatePodcasts(force) + } + // TODO: look at result of runCatching and show any errors + + refreshing.value = false + } + } + + fun onHomeAction(action: HomeAction) { + when (action) { + is HomeAction.CategorySelected -> onCategorySelected(action.category) + is HomeAction.HomeCategorySelected -> onHomeCategorySelected(action.category) + is HomeAction.LibraryPodcastSelected -> onLibraryPodcastSelected(action.podcast) + is HomeAction.PodcastUnfollowed -> onPodcastUnfollowed(action.podcast) + is HomeAction.QueueEpisode -> onQueueEpisode(action.episode) + is HomeAction.RemoveEpisode -> deleteEpisode(action.episodeInfo) + is HomeAction.TogglePodcastFollowed -> onTogglePodcastFollowed(action.podcast) + } + } + + private fun onCategorySelected(category: CategoryInfo) { + _selectedCategory.value = category + } + + private fun onHomeCategorySelected(category: HomeCategory) { + selectedHomeCategory.value = category + } + + private fun onPodcastUnfollowed(podcast: PodcastInfo) { + viewModelScope.launch { + podcastStore.unfollowPodcast(podcast.uri) + } + } + + private fun onTogglePodcastFollowed(podcast: PodcastInfo) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + + private fun onLibraryPodcastSelected(podcast: PodcastInfo?) { + selectedLibraryPodcast.value = podcast + } + + private fun onQueueEpisode(episode: PlayerEpisode) { + episodePlayer.addToQueue(episode) + } + + fun deleteEpisode(episode: EpisodeInfo) { + viewModelScope.launch { + episodeStore.deleteEpisode(episode.asDaoModel()) + } + } +} + +private fun List.asLibrary(): LibraryInfo = LibraryInfo( + episodes = this.map { it.asPodcastToEpisodeInfo() }, +) + +enum class HomeCategory { + Library, + Discover, +} + +@Immutable +sealed interface HomeAction { + data class CategorySelected(val category: CategoryInfo) : HomeAction + data class HomeCategorySelected(val category: HomeCategory) : HomeAction + data class PodcastUnfollowed(val podcast: PodcastInfo) : HomeAction + data class TogglePodcastFollowed(val podcast: PodcastInfo) : HomeAction + data class LibraryPodcastSelected(val podcast: PodcastInfo?) : HomeAction + data class QueueEpisode(val episode: PlayerEpisode) : HomeAction + data class RemoveEpisode(val episodeInfo: EpisodeInfo) : HomeAction +} + +@Immutable +data class HomeScreenUiState( + val isLoading: Boolean = true, + val errorMessage: String? = null, + val featuredPodcasts: ImmutableList = persistentListOf(), + val selectedHomeCategory: HomeCategory = HomeCategory.Discover, + val homeCategories: List = emptyList(), + val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(), + val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(), + val library: LibraryInfo = LibraryInfo(), +) diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt new file mode 100644 index 0000000000..fa84db7012 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalSharedTransitionApi::class) + +package com.example.jetcaster.ui.home.category + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +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.layout.width +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.carousel.HorizontalUncontainedCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetcaster.core.domain.testing.PreviewEpisodes +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.ui.LocalAnimatedVisibilityScope +import com.example.jetcaster.ui.LocalSharedTransitionScope +import com.example.jetcaster.ui.shared.EpisodeListItem +import com.example.jetcaster.ui.theme.JetcasterTheme +import com.example.jetcaster.util.ToggleFollowPodcastIconButton +import com.example.jetcaster.util.fullWidthItem + +fun LazyGridScope.podcastCategory( + podcastCategoryFilterResult: PodcastCategoryFilterResult, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + removeFromQueue: (EpisodeInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, +) { + fullWidthItem { + CategoryPodcasts( + topPodcasts = podcastCategoryFilterResult.topPodcasts, + navigateToPodcastDetails = navigateToPodcastDetails, + onTogglePodcastFollowed = onTogglePodcastFollowed, + ) + } + + val episodes = podcastCategoryFilterResult.episodes + items(episodes, key = { it.episode.uri }) { item -> + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No SharedElementScope found") + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + ?: throw IllegalStateException("No SharedElementScope found") + with(sharedTransitionScope) { + EpisodeListItem( + episode = item.episode, + podcast = item.podcast, + onClick = navigateToPlayer, + onQueueEpisode = onQueueEpisode, + modifier = Modifier + .fillMaxWidth() + .animateItem(), + imageModifier = Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = item.episode.title, + ), + animatedVisibilityScope = animatedVisibilityScope, + clipInOverlayDuringTransition = OverlayClip(MaterialTheme.shapes.medium), + ), + removeFromQueue = removeFromQueue, + ) + } + } +} + +@Composable +private fun CategoryPodcasts( + topPodcasts: List, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, +) { + CategoryPodcastRow( + podcasts = topPodcasts, + onTogglePodcastFollowed = onTogglePodcastFollowed, + navigateToPodcastDetails = navigateToPodcastDetails, + modifier = Modifier.fillMaxWidth(), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CategoryPodcastRow( + podcasts: List, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, +) { + HorizontalUncontainedCarousel( + state = rememberCarouselState { podcasts.count() }, + modifier = modifier.padding(start = 8.dp), + itemWidth = 128.dp, + itemSpacing = 4.dp, + ) { i -> + val podcast = podcasts[i] + TopPodcastRowItem( + podcastTitle = podcast.title, + podcastImageUrl = podcast.imageUrl, + isFollowed = podcast.isSubscribed ?: false, + onToggleFollowClicked = { onTogglePodcastFollowed(podcast) }, + modifier = Modifier + .width(128.dp) + .clickable { + navigateToPodcastDetails(podcast) + } + .maskClip(MaterialTheme.shapes.large), + ) + } +} + +@Composable +private fun TopPodcastRowItem( + podcastTitle: String, + podcastImageUrl: String, + isFollowed: Boolean, + modifier: Modifier = Modifier, + onToggleFollowClicked: () -> Unit, +) { + val gradient = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) + + Box( + modifier + .fillMaxWidth() + .height(128.dp) + .aspectRatio(1f) + .clip(MaterialTheme.shapes.large), + ) { + PodcastImage( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium), + podcastImageUrl = podcastImageUrl, + contentDescription = podcastTitle, + ) + + ToggleFollowPodcastIconButton( + onClick = onToggleFollowClicked, + isFollowed = isFollowed, + modifier = Modifier.align(Alignment.TopStart), + ) + + Box(modifier = Modifier.matchParentSize().background(gradient)) + + Text( + text = podcastTitle, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp, bottom = 16.dp), + ) + } +} + +@Preview +@Composable +fun PreviewEpisodeListItem() { + JetcasterTheme { + EpisodeListItem( + episode = PreviewEpisodes[0], + podcast = PreviewPodcasts[0], + onClick = { }, + onQueueEpisode = { }, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt new file mode 100644 index 0000000000..d4116c4ff5 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.home.discover + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.ui.home.category.podcastCategory +import com.example.jetcaster.util.fullWidthItem + +fun LazyGridScope.discoverItems( + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + removeFromQueue: (EpisodeInfo) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, +) { + if (filterableCategoriesModel.isEmpty) { + // TODO: empty state + return + } + + fullWidthItem { + Spacer(Modifier.height(8.dp)) + + PodcastCategoryTabs( + filterableCategoriesModel = filterableCategoriesModel, + onCategorySelected = onCategorySelected, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + } + + podcastCategory( + podcastCategoryFilterResult = podcastCategoryFilterResult, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode, + removeFromQueue = removeFromQueue, + ) +} + +@Composable +private fun PodcastCategoryTabs( + filterableCategoriesModel: FilterableCategoriesModel, + onCategorySelected: (CategoryInfo) -> Unit, + modifier: Modifier = Modifier, +) { + val selectedIndex = filterableCategoriesModel.categories.indexOf( + filterableCategoriesModel.selectedCategory, + ) + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(horizontal = Keyline1), + verticalAlignment = Alignment.CenterVertically, + ) { + itemsIndexed( + items = filterableCategoriesModel.categories, + key = { i, category -> category.id }, + ) { index, category -> + ChoiceChipContent( + text = category.name, + selected = index == selectedIndex, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), + onClick = { onCategorySelected(category) }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChoiceChipContent(text: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + FilterChip( + selected = selected, + onClick = onClick, + leadingIcon = { + if (selected) { + Icon( + painter = painterResource(id = R.drawable.ic_check), + contentDescription = stringResource(id = R.string.cd_selected_category), + modifier = Modifier.height(18.dp), + ) + } + }, + label = { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + }, + colors = FilterChipDefaults.filterChipColors().copy( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + shape = MaterialTheme.shapes.large, + border = null, + modifier = modifier, + ) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/library/Library.kt new file mode 100644 index 0000000000..95c8109446 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.home.library + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.LibraryInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.ui.shared.EpisodeListItem +import com.example.jetcaster.util.fullWidthItem + +fun LazyGridScope.libraryItems( + library: LibraryInfo, + navigateToPlayer: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + removeFromQueue: (EpisodeInfo) -> Unit, +) { + fullWidthItem { + Text( + text = stringResource(id = R.string.latest_episodes), + modifier = Modifier.padding( + start = Keyline1, + top = 16.dp, + ), + style = MaterialTheme.typography.headlineMedium, + ) + } + + items( + library, + key = { it.episode.uri }, + ) { item -> + EpisodeListItem( + episode = item.episode, + podcast = item.podcast, + onClick = navigateToPlayer, + onQueueEpisode = onQueueEpisode, + modifier = Modifier + .fillMaxWidth() + .animateItem(), + removeFromQueue = removeFromQueue, + ) + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt new file mode 100644 index 0000000000..de8c8b4de0 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -0,0 +1,946 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalSharedTransitionApi::class) + +package com.example.jetcaster.ui.player + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonColors +import androidx.compose.material3.ToggleButtonShapes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.computeWindowSizeClass +import androidx.window.layout.DisplayFeature +import androidx.window.layout.FoldingFeature +import com.example.jetcaster.R +import com.example.jetcaster.core.player.EpisodePlayerState +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.HtmlTextContainer +import com.example.jetcaster.designsystem.component.ImageBackgroundColorScrim +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.ui.LocalAnimatedVisibilityScope +import com.example.jetcaster.ui.LocalSharedTransitionScope +import com.example.jetcaster.ui.theme.JetcasterTheme +import com.example.jetcaster.ui.tooling.DevicePreviews +import com.example.jetcaster.util.isBookPosture +import com.example.jetcaster.util.isSeparatingPosture +import com.example.jetcaster.util.isTableTopPosture +import com.example.jetcaster.util.verticalGradientScrim +import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy +import com.google.accompanist.adaptive.TwoPane +import com.google.accompanist.adaptive.VerticalTwoPaneStrategy +import java.time.Duration +import kotlinx.coroutines.launch + +/** + * Stateful version of the Podcast player + */ +@Composable +fun PlayerScreen( + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + viewModel: PlayerViewModel = hiltViewModel(), +) { + val uiState = viewModel.uiState + PlayerScreen( + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onAddToQueue = viewModel::onAddToQueue, + onStop = viewModel::onStop, + playerControlActions = PlayerControlActions( + onPlayPress = viewModel::onPlay, + onPausePress = viewModel::onPause, + onAdvanceBy = viewModel::onAdvanceBy, + onRewindBy = viewModel::onRewindBy, + onSeekingStarted = viewModel::onSeekingStarted, + onSeekingFinished = viewModel::onSeekingFinished, + onNext = viewModel::onNext, + onPrevious = viewModel::onPrevious, + ), + ) +} + +/** + * Stateless version of the Player screen + */ +@Composable +private fun PlayerScreen( + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + onStop: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier, +) { + DisposableEffect(Unit) { + onDispose { + onStop() + } + } + + val coroutineScope = rememberCoroutineScope() + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + val snackbarHostState = remember { SnackbarHostState() } + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + modifier = modifier, + ) { contentPadding -> + if (uiState.episodePlayerState.currentEpisode != null) { + PlayerContentWithBackground( + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onAddToQueue = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + onAddToQueue() + }, + playerControlActions = playerControlActions, + contentPadding = contentPadding, + ) + } else { + FullScreenLoading() + } + } +} + +@Composable +private fun PlayerBackground(episode: PlayerEpisode?, modifier: Modifier) { + ImageBackgroundColorScrim( + url = episode?.podcastImageUrl, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + modifier = modifier, + ) +} + +@Composable +fun PlayerContentWithBackground( + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + PlayerBackground( + episode = uiState.episodePlayerState.currentEpisode, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) + PlayerContent( + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + playerControlActions = playerControlActions, + ) + } +} + +/** + * Wrapper around all actions for the player controls. + */ +data class PlayerControlActions( + val onPlayPress: () -> Unit, + val onPausePress: () -> Unit, + val onAdvanceBy: (Duration) -> Unit, + val onRewindBy: (Duration) -> Unit, + val onNext: () -> Unit, + val onPrevious: () -> Unit, + val onSeekingStarted: () -> Unit, + val onSeekingFinished: (newElapsed: Duration) -> Unit, +) + +@Composable +fun PlayerContent( + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier, +) { + val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() + + // Use a two pane layout if there is a fold impacting layout (meaning it is separating + // or non-flat) or if we have a large enough width to show both. + if ( + windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) || + isBookPosture(foldingFeature) || + isTableTopPosture(foldingFeature) || + isSeparatingPosture(foldingFeature) + ) { + // Determine if we are going to be using a vertical strategy (as if laying out + // both sides in a column). We want to do so if we are in a tabletop posture, + // or we have an impactful horizontal fold. Otherwise, we'll use a horizontal strategy. + val usingVerticalStrategy = + isTableTopPosture(foldingFeature) || + ( + isSeparatingPosture(foldingFeature) && + foldingFeature.orientation == + FoldingFeature.Orientation.HORIZONTAL + ) + + if (usingVerticalStrategy) { + TwoPane( + first = { + PlayerContentTableTopTop( + uiState = uiState, + ) + }, + second = { + PlayerContentTableTopBottom( + uiState = uiState, + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + playerControlActions = playerControlActions, + ) + }, + strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures, + modifier = modifier, + ) + } else { + Column( + modifier = modifier + .fillMaxSize() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f, + ) + .systemBarsPadding() + .padding(horizontal = 8.dp), + ) { + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) + TwoPane( + first = { + PlayerContentBookStart(uiState = uiState) + }, + second = { + PlayerContentBookEnd( + uiState = uiState, + playerControlActions = playerControlActions, + ) + }, + strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures, + ) + } + } + } else { + PlayerContentRegular( + uiState = uiState, + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + playerControlActions = playerControlActions, + modifier = modifier, + ) + } +} + +/** + * The UI for the top pane of a tabletop layout. + */ +@Composable +private fun PlayerContentRegular( + uiState: PlayerUiState, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier, +) { + val playerEpisode = uiState.episodePlayerState + val currentEpisode = playerEpisode.currentEpisode ?: return + + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No SharedElementScope found") + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + ?: throw IllegalStateException("No SharedElementScope found") + + Column( + modifier = modifier + .fillMaxSize() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f, + ) + .systemBarsPadding() + .padding(horizontal = 8.dp), + ) { + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + Spacer(modifier = Modifier.weight(1f)) + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + PlayerImage( + podcastImageUrl = currentEpisode.podcastImageUrl, + modifier = Modifier + .weight(10f) + .animateEnterExit( + enter = fadeIn(spring(stiffness = Spring.StiffnessLow)), + exit = fadeOut(), + ), + imageModifier = Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = currentEpisode.title, + ), + animatedVisibilityScope = animatedVisibilityScope, + clipInOverlayDuringTransition = + OverlayClip(MaterialTheme.shapes.medium), + ), + ) + } + Spacer(modifier = Modifier.height(32.dp)) + PodcastDescription(currentEpisode.title, currentEpisode.podcastName) + Spacer(modifier = Modifier.height(32.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(10f), + ) { + PlayerSlider( + timeElapsed = playerEpisode.timeElapsed, + episodeDuration = currentEpisode.duration, + onSeekingStarted = playerControlActions.onSeekingStarted, + onSeekingFinished = playerControlActions.onSeekingFinished, + ) + PlayerButtons( + hasNext = playerEpisode.queue.isNotEmpty(), + isPlaying = playerEpisode.isPlaying, + onPlayPress = playerControlActions.onPlayPress, + onPausePress = playerControlActions.onPausePress, + onAdvanceBy = playerControlActions.onAdvanceBy, + onRewindBy = playerControlActions.onRewindBy, + onNext = playerControlActions.onNext, + onPrevious = playerControlActions.onPrevious, + Modifier.padding(vertical = 8.dp), + ) + } + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +/** + * The UI for the top pane of a tabletop layout. + */ +@Composable +private fun PlayerContentTableTopTop(uiState: PlayerUiState, modifier: Modifier = Modifier) { + // Content for the top part of the screen + val episode = uiState.episodePlayerState.currentEpisode ?: return + Column( + modifier = modifier + .fillMaxWidth() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f, + ) + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Top, + ), + ) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PlayerImage(episode.podcastImageUrl) + } +} + +/** + * The UI for the bottom pane of a tabletop layout. + */ +@Composable +private fun PlayerContentTableTopBottom( + uiState: PlayerUiState, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier, +) { + val episodePlayerState = uiState.episodePlayerState + val episode = uiState.episodePlayerState.currentEpisode ?: return + // Content for the table part of the screen + Column( + modifier = modifier + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, + ), + ) + .padding(horizontal = 32.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) + PodcastDescription( + title = episode.title, + podcastName = episode.podcastName, + ) + Spacer(modifier = Modifier.weight(0.5f)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(10f), + ) { + PlayerButtons( + hasNext = episodePlayerState.queue.isNotEmpty(), + isPlaying = episodePlayerState.isPlaying, + onPlayPress = playerControlActions.onPlayPress, + onPausePress = playerControlActions.onPausePress, + onAdvanceBy = playerControlActions.onAdvanceBy, + onRewindBy = playerControlActions.onRewindBy, + onNext = playerControlActions.onNext, + onPrevious = playerControlActions.onPrevious, + modifier = Modifier.padding(top = 8.dp), + ) + PlayerSlider( + timeElapsed = episodePlayerState.timeElapsed, + episodeDuration = episode.duration, + onSeekingStarted = playerControlActions.onSeekingStarted, + onSeekingFinished = playerControlActions.onSeekingFinished, + ) + } + } +} + +/** + * The UI for the start pane of a book layout. + */ +@Composable +private fun PlayerContentBookStart(uiState: PlayerUiState, modifier: Modifier = Modifier) { + val episode = uiState.episodePlayerState.currentEpisode ?: return + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + vertical = 40.dp, + horizontal = 16.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PodcastInformation( + title = episode.title, + name = episode.podcastName, + summary = episode.summary, + ) + } +} + +/** + * The UI for the end pane of a book layout. + */ +@Composable +private fun PlayerContentBookEnd(uiState: PlayerUiState, playerControlActions: PlayerControlActions, modifier: Modifier = Modifier) { + val episodePlayerState = uiState.episodePlayerState + val episode = episodePlayerState.currentEpisode ?: return + Column( + modifier = modifier + .fillMaxSize() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + ) { + PlayerImage( + podcastImageUrl = episode.podcastImageUrl, + modifier = Modifier + .padding(vertical = 16.dp) + .weight(1f), + ) + PlayerSlider( + timeElapsed = episodePlayerState.timeElapsed, + episodeDuration = episode.duration, + onSeekingStarted = playerControlActions.onSeekingStarted, + onSeekingFinished = playerControlActions.onSeekingFinished, + ) + PlayerButtons( + hasNext = episodePlayerState.queue.isNotEmpty(), + isPlaying = episodePlayerState.isPlaying, + onPlayPress = playerControlActions.onPlayPress, + onPausePress = playerControlActions.onPausePress, + onAdvanceBy = playerControlActions.onAdvanceBy, + onRewindBy = playerControlActions.onRewindBy, + onNext = playerControlActions.onNext, + onPrevious = playerControlActions.onPrevious, + Modifier.padding(vertical = 8.dp), + ) + } +} + +@Composable +private fun TopAppBar(onBackPress: () -> Unit, onAddToQueue: () -> Unit) { + Row(Modifier.fillMaxWidth()) { + IconButton(onClick = onBackPress) { + Icon( + painterResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.cd_back), + ) + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = onAddToQueue) { + Icon( + painterResource(id = R.drawable.ic_playlist_add), + contentDescription = stringResource(R.string.cd_add), + ) + } + IconButton(onClick = { /* TODO */ }) { + Icon( + painterResource(id = R.drawable.ic_more_vert), + contentDescription = stringResource(R.string.cd_more), + ) + } + } +} + +@Composable +private fun PlayerImage(podcastImageUrl: String, modifier: Modifier = Modifier, imageModifier: Modifier = Modifier) { + PodcastImage( + podcastImageUrl = podcastImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .sizeIn(maxWidth = 500.dp, maxHeight = 500.dp) + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium), + imageModifier = imageModifier, + ) +} + +@Composable +private fun PodcastDescription(title: String, podcastName: String) { + Text( + text = title, + style = MaterialTheme.typography.displayLarge, + maxLines = 2, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.basicMarquee(), + ) + Text( + text = podcastName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) +} + +@Composable +private fun PodcastInformation( + title: String, + name: String, + summary: String, + modifier: Modifier = Modifier, + titleTextStyle: TextStyle = MaterialTheme.typography.headlineLarge, + nameTextStyle: TextStyle = MaterialTheme.typography.displaySmall, +) { + Column( + modifier = modifier.padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = name, + style = nameTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = title, + style = titleTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + HtmlTextContainer(text = summary) { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current, + ) + } + } +} + +fun Duration.formatString(): String { + val minutes = this.toMinutes().toString().padStart(2, '0') + val secondsLeft = (this.toSeconds() % 60).toString().padStart(2, '0') + return "$minutes:$secondsLeft" +} + +@Composable +private fun PlayerSlider( + timeElapsed: Duration, + episodeDuration: Duration?, + onSeekingStarted: () -> Unit, + onSeekingFinished: (newElapsed: Duration) -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + var sliderValue by remember(timeElapsed) { mutableStateOf(timeElapsed) } + val maxRange = (episodeDuration?.toSeconds() ?: 0).toFloat() + + Row(Modifier.fillMaxWidth()) { + Text( + text = "${sliderValue.formatString()} • ${episodeDuration?.formatString()}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Slider( + value = sliderValue.seconds.toFloat(), + valueRange = 0f..maxRange, + onValueChange = { + onSeekingStarted() + sliderValue = Duration.ofSeconds(it.toLong()) + }, + onValueChangeFinished = { onSeekingFinished(sliderValue) }, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PlayerButtons( + hasNext: Boolean, + isPlaying: Boolean, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + ToggleButton( + checked = isPlaying, + onCheckedChange = { + if (isPlaying) { + onPausePress() + } else { + onPlayPress() + } + }, + colors = ToggleButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.onPrimary, + checkedContainerColor = MaterialTheme.colorScheme.tertiary, + checkedContentColor = MaterialTheme.colorScheme.onTertiary, + ), + shapes = ToggleButtonShapes( + shape = RoundedCornerShape(60.dp), + pressedShape = RoundedCornerShape(if (isPlaying) 60.dp else 30.dp), + checkedShape = RoundedCornerShape(30.dp), + ), + modifier = Modifier + .width(186.dp) + .height(136.dp), + ) { + Icon( + painterResource(id = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow), + modifier = Modifier.fillMaxSize(), + contentDescription = null, + ) + } + ButtonGroup( + overflowIndicator = {}, + modifier = Modifier.padding(vertical = 16.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val skipButtonsModifier = Modifier + .width(56.dp) + .height(68.dp) + + val rewindFastForwardButtonsModifier = Modifier + .size(68.dp) + val interactionSources = List(4) { MutableInteractionSource() } + customItem( + buttonGroupContent = { + IconButton( + onClick = onPrevious, + modifier = skipButtonsModifier.animateWidth(interactionSource = interactionSources[0]), + shape = RoundedCornerShape(50.dp), + colors = IconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContentColor = MaterialTheme.colorScheme.onPrimary, + ), + interactionSource = interactionSources[0], + enabled = isPlaying, + ) { + Icon( + painterResource(id = R.drawable.ic_skip_previous), + contentDescription = null, + ) + } + }, + menuContent = { }, + ) + + customItem( + buttonGroupContent = { + IconButton( + onClick = { onRewindBy(Duration.ofSeconds(10)) }, + modifier = rewindFastForwardButtonsModifier.animateWidth(interactionSource = interactionSources[1]), + shape = RoundedCornerShape(15.dp), + colors = IconButtonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + disabledContainerColor = MaterialTheme.colorScheme.secondary, + disabledContentColor = MaterialTheme.colorScheme.onSecondary, + ), + interactionSource = interactionSources[1], + enabled = isPlaying, + ) { + Icon( + painterResource(id = R.drawable.ic_replay_10), + contentDescription = null, + ) + } + }, + menuContent = { }, + ) + + customItem( + buttonGroupContent = { + IconButton( + onClick = { onAdvanceBy(Duration.ofSeconds(10)) }, + modifier = rewindFastForwardButtonsModifier.animateWidth(interactionSource = interactionSources[2]), + shape = RoundedCornerShape(15.dp), + colors = IconButtonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + disabledContainerColor = MaterialTheme.colorScheme.secondary, + disabledContentColor = MaterialTheme.colorScheme.onSecondary, + ), + interactionSource = interactionSources[2], + enabled = isPlaying, + ) { + Icon( + painterResource(id = R.drawable.ic_forward_10), + contentDescription = null, + ) + } + }, + menuContent = { }, + ) + + customItem( + buttonGroupContent = { + IconButton( + onClick = onNext, + modifier = skipButtonsModifier.animateWidth(interactionSource = interactionSources[3]), + shape = RoundedCornerShape(50.dp), + colors = IconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContentColor = MaterialTheme.colorScheme.onSurface, + ), + interactionSource = interactionSources[3], + enabled = hasNext, + ) { + Icon( + painterResource(id = R.drawable.ic_skip_next), + contentDescription = null, + ) + } + }, + menuContent = { }, + ) + } + } +} + +/** + * Full screen circular progress indicator + */ +@Composable +private fun FullScreenLoading(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center), + ) { + CircularProgressIndicator() + } +} + +@Preview +@Composable +fun TopAppBarPreview() { + JetcasterTheme { + TopAppBar( + onBackPress = {}, + onAddToQueue = {}, + ) + } +} + +@Preview +@Composable +fun PlayerButtonsPreview() { + JetcasterTheme { + PlayerButtons( + hasNext = false, + isPlaying = true, + onPlayPress = {}, + onPausePress = {}, + onAdvanceBy = {}, + onRewindBy = {}, + onNext = {}, + onPrevious = {}, + ) + } +} + +@DevicePreviews +@Composable +fun PlayerScreenPreview() { + JetcasterTheme { + BoxWithConstraints { + PlayerScreen( + PlayerUiState( + episodePlayerState = EpisodePlayerState( + currentEpisode = PlayerEpisode( + title = "Title", + duration = Duration.ofHours(2), + podcastName = "Podcast", + ), + isPlaying = false, + queue = listOf( + PlayerEpisode(), + PlayerEpisode(), + PlayerEpisode(), + ), + ), + ), + displayFeatures = emptyList(), + windowSizeClass = WindowSizeClass.BREAKPOINTS_V1.computeWindowSizeClass(maxWidth.value, maxHeight.value), + onBackPress = { }, + onAddToQueue = {}, + onStop = {}, + playerControlActions = PlayerControlActions( + onPlayPress = {}, + onPausePress = {}, + onAdvanceBy = {}, + onRewindBy = {}, + onSeekingStarted = {}, + onSeekingFinished = {}, + onNext = {}, + onPrevious = {}, + ), + ) + } + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt new file mode 100644 index 0000000000..a123007908 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.player + +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.EpisodePlayerState +import com.example.jetcaster.core.player.model.toPlayerEpisode +import com.example.jetcaster.ui.Screen +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Duration +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +data class PlayerUiState(val episodePlayerState: EpisodePlayerState = EpisodePlayerState()) + +/** + * ViewModel that handles the business logic and screen state of the Player screen + */ +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class PlayerViewModel @Inject constructor( + episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + // episodeUri should always be present in the PlayerViewModel. + // If that's not the case, fail crashing the app! + private val episodeUri: String = + Uri.decode(savedStateHandle.get(Screen.ARG_EPISODE_URI)!!) + + var uiState by mutableStateOf(PlayerUiState()) + private set + + init { + viewModelScope.launch { + episodeStore.episodeAndPodcastWithUri(episodeUri).flatMapConcat { + episodePlayer.currentEpisode = it.toPlayerEpisode() + episodePlayer.playerState + }.map { + PlayerUiState(episodePlayerState = it) + }.collect { + uiState = it + } + } + } + + fun onPlay() { + episodePlayer.play() + } + + fun onPause() { + episodePlayer.pause() + } + + fun onStop() { + episodePlayer.stop() + } + + fun onPrevious() { + episodePlayer.previous() + } + + fun onNext() { + episodePlayer.next() + } + + fun onAdvanceBy(duration: Duration) { + episodePlayer.advanceBy(duration) + } + + fun onRewindBy(duration: Duration) { + episodePlayer.rewindBy(duration) + } + + fun onSeekingStarted() { + episodePlayer.onSeekingStarted() + } + + fun onSeekingFinished(duration: Duration) { + episodePlayer.onSeekingFinished(duration) + } + + fun onAddToQueue() { + uiState.episodePlayerState.currentEpisode?.let { + episodePlayer.addToQueue(it) + } + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt new file mode 100644 index 0000000000..c9890ab707 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -0,0 +1,405 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.podcast + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.EaseOutExpo +import androidx.compose.animation.core.tween +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.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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonColors +import androidx.compose.material3.ToggleButtonShapes +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetcaster.R +import com.example.jetcaster.core.domain.testing.PreviewEpisodes +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.ui.shared.EpisodeListItem +import com.example.jetcaster.ui.shared.Loading +import com.example.jetcaster.ui.tooling.DevicePreviews +import com.example.jetcaster.util.fullWidthItem +import kotlinx.coroutines.launch + +@Composable +fun PodcastDetailsScreen( + viewModel: PodcastDetailsViewModel, + navigateToPlayer: (EpisodeInfo) -> Unit, + navigateBack: () -> Unit, + showBackButton: Boolean, + modifier: Modifier = Modifier, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + when (val s = state) { + is PodcastUiState.Loading -> { + PodcastDetailsLoadingScreen( + modifier = Modifier.fillMaxSize(), + ) + } + + is PodcastUiState.Ready -> { + PodcastDetailsScreen( + podcast = s.podcast, + episodes = s.episodes, + toggleSubscribe = viewModel::toggleSubscribe, + onQueueEpisode = viewModel::onQueueEpisode, + removeFromQueue = viewModel::deleteEpisode, + navigateToPlayer = navigateToPlayer, + navigateBack = navigateBack, + showBackButton = showBackButton, + modifier = modifier, + ) + } + } +} + +@Composable +private fun PodcastDetailsLoadingScreen(modifier: Modifier = Modifier) { + Loading(modifier = modifier) +} + +@Composable +fun PodcastDetailsScreen( + podcast: PodcastInfo, + episodes: List, + toggleSubscribe: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + navigateBack: () -> Unit, + showBackButton: Boolean, + modifier: Modifier = Modifier, + removeFromQueue: (EpisodeInfo) -> Unit = {}, +) { + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + if (showBackButton) { + PodcastDetailsTopAppBar( + navigateBack = navigateBack, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { contentPadding -> + PodcastDetailsContent( + podcast = podcast, + episodes = episodes, + toggleSubscribe = toggleSubscribe, + removeFromQueue = removeFromQueue, + onQueueEpisode = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + onQueueEpisode(it) + }, + navigateToPlayer = navigateToPlayer, + modifier = Modifier.padding(contentPadding), + ) + } +} + +@Composable +fun PodcastDetailsContent( + podcast: PodcastInfo, + episodes: List, + removeFromQueue: (EpisodeInfo) -> Unit, + toggleSubscribe: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + modifier: Modifier = Modifier, +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(362.dp), + modifier.fillMaxSize(), + ) { + fullWidthItem { + PodcastDetailsHeaderItem( + podcast = podcast, + toggleSubscribe = toggleSubscribe, + modifier = Modifier.fillMaxWidth(), + ) + } + items(episodes, key = { it.uri }) { episode -> + EpisodeListItem( + episode = episode, + podcast = podcast, + onClick = navigateToPlayer, + removeFromQueue = removeFromQueue, + onQueueEpisode = onQueueEpisode, + modifier = Modifier + .fillMaxWidth() + .animateItem(), + showPodcastImage = false, + showSummary = true, + ) + } + } +} + +@Composable +fun PodcastDetailsHeaderItem(podcast: PodcastInfo, toggleSubscribe: (PodcastInfo) -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier.padding(Keyline1), + ) { + Column { + PodcastImage( + modifier = Modifier + .size(280.dp) + .clip(MaterialTheme.shapes.large) + .align(Alignment.CenterHorizontally), + podcastImageUrl = podcast.imageUrl, + contentDescription = podcast.title, + ) + Text( + text = podcast.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displayMedium, + modifier = Modifier.padding(top = 16.dp), + ) + PodcastDetailsDescription( + podcast = podcast, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) + PodcastDetailsHeaderItemButtons( + isSubscribed = podcast.isSubscribed ?: false, + onClick = { + toggleSubscribe(podcast) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +fun PodcastDetailsDescription(podcast: PodcastInfo, modifier: Modifier) { + var isExpanded by remember { mutableStateOf(false) } + var showSeeMore by remember { mutableStateOf(false) } + Box( + modifier = modifier.clickable { isExpanded = !isExpanded }, + ) { + Text( + text = podcast.description, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (isExpanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + onTextLayout = { result -> + showSeeMore = result.hasVisualOverflow + }, + modifier = Modifier.animateContentSize( + animationSpec = tween( + durationMillis = 200, + easing = EaseOutExpo, + ), + ), + ) + if (showSeeMore) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .background(MaterialTheme.colorScheme.surface), + ) { + Text( + text = stringResource(id = R.string.see_more), + style = MaterialTheme.typography.bodyMedium.copy( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + ), + modifier = Modifier.padding(start = 16.dp), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun PodcastDetailsHeaderItemButtons(isSubscribed: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + var isNotificationOn by remember { mutableStateOf(false) } + val interactionSource1 = remember { MutableInteractionSource() } + val interactionSource2 = remember { MutableInteractionSource() } + ButtonGroup( + overflowIndicator = {}, + modifier = modifier, + ) { + customItem( + buttonGroupContent = { + ToggleButton( + checked = isSubscribed, + onCheckedChange = { onClick() }, + colors = ToggleButtonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + disabledContainerColor = MaterialTheme.colorScheme.inverseSurface, + disabledContentColor = MaterialTheme.colorScheme.surfaceVariant, + checkedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + checkedContentColor = MaterialTheme.colorScheme.secondary, + ), + shapes = ToggleButtonShapes( + shape = RoundedCornerShape(15.dp), + pressedShape = RoundedCornerShape(if (isSubscribed) 15.dp else 60.dp), + checkedShape = RoundedCornerShape(60.dp), + ), + modifier = Modifier + .width(76.dp) + .height(56.dp) + .animateWidth(interactionSource = interactionSource1) + .semantics(mergeDescendants = true) { }, + interactionSource = interactionSource1, + ) { + Icon( + painterResource(id = if (isSubscribed) R.drawable.ic_check else R.drawable.ic_add), + contentDescription = null, + ) + } + }, + menuContent = { }, + ) + + customItem( + buttonGroupContent = { + ToggleButton( + checked = isNotificationOn, + onCheckedChange = { isNotificationOn = !isNotificationOn }, + colors = ToggleButtonColors( + containerColor = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContainerColor = MaterialTheme.colorScheme.inverseSurface, + disabledContentColor = MaterialTheme.colorScheme.surfaceVariant, + checkedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + checkedContentColor = MaterialTheme.colorScheme.secondary, + ), + shapes = ToggleButtonShapes( + shape = RoundedCornerShape(100.dp), + pressedShape = RoundedCornerShape(if (isNotificationOn) 100.dp else 20.dp), + checkedShape = RoundedCornerShape(20.dp), + ), + interactionSource = interactionSource2, + modifier = Modifier + .size(56.dp) + .animateWidth(interactionSource = interactionSource2), + ) { + Icon( + painterResource(id = if (isNotificationOn) R.drawable.ic_notifications_active else R.drawable.ic_notifications), + contentDescription = stringResource(R.string.cd_more), + ) + } + }, + menuContent = {}, + ) + customItem( + buttonGroupContent = { Spacer(modifier.weight(1f)) }, + menuContent = { }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PodcastDetailsTopAppBar(navigateBack: () -> Unit, modifier: Modifier = Modifier) { + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + painterResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(id = R.string.cd_back), + ) + } + }, + modifier = modifier, + ) +} + +@Preview +@Composable +fun PodcastDetailsHeaderItemPreview() { + PodcastDetailsHeaderItem( + podcast = PreviewPodcasts[0], + toggleSubscribe = { }, + ) +} + +@DevicePreviews +@Composable +fun PodcastDetailsScreenPreview() { + PodcastDetailsScreen( + podcast = PreviewPodcasts[0], + episodes = PreviewEpisodes, + toggleSubscribe = { }, + onQueueEpisode = { }, + navigateToPlayer = { }, + navigateBack = { }, + showBackButton = true, + ) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt new file mode 100644 index 0000000000..d77b68d804 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.podcast + +import android.net.Uri +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.model.asDaoModel +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@Immutable +sealed interface PodcastUiState { + data object Loading : PodcastUiState + data class Ready(val podcast: PodcastInfo, val episodes: List) : PodcastUiState +} + +/** + * ViewModel that handles the business logic and screen state of the Podcast details screen. + */ +@HiltViewModel(assistedFactory = PodcastDetailsViewModel.Factory::class) +class PodcastDetailsViewModel @AssistedInject constructor( + private val episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, + private val podcastStore: PodcastStore, + @Assisted private val podcastUri: String, +) : ViewModel() { + + private val decodedPodcastUri = Uri.decode(podcastUri) + + val state: StateFlow = + combine( + podcastStore.podcastWithExtraInfo(decodedPodcastUri), + episodeStore.episodesInPodcast(decodedPodcastUri), + ) { podcast, episodeToPodcasts -> + val episodes = episodeToPodcasts.map { it.episode.asExternalModel() } + PodcastUiState.Ready( + podcast = podcast.podcast.asExternalModel().copy(isSubscribed = podcast.isFollowed), + episodes = episodes, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PodcastUiState.Loading, + ) + + fun toggleSubscribe(podcast: PodcastInfo) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + + fun onQueueEpisode(playerEpisode: PlayerEpisode) { + episodePlayer.addToQueue(playerEpisode) + } + + fun deleteEpisode(episodeInfo: EpisodeInfo) { + viewModelScope.launch { + episodeStore.deleteEpisode(episodeInfo.asDaoModel()) + } + } + + @AssistedFactory + interface Factory { + fun create(podcastUri: String): PodcastDetailsViewModel + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt new file mode 100644 index 0000000000..0e04d221ba --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -0,0 +1,314 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.shared + +import android.content.res.Configuration +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R +import com.example.jetcaster.core.domain.testing.PreviewEpisodes +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.HtmlTextContainer +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.ui.theme.JetcasterTheme +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun EpisodeListItem( + episode: EpisodeInfo, + podcast: PodcastInfo, + onClick: (EpisodeInfo) -> Unit, + removeFromQueue: (EpisodeInfo) -> Unit = {}, + onQueueEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier, + showPodcastImage: Boolean = true, + showSummary: Boolean = false, +) { + val dismissState = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + modifier = modifier, + state = dismissState, + enableDismissFromStartToEnd = false, + backgroundContent = { + Box( + modifier = Modifier + .fillMaxSize() + .padding(end = 40.dp), + ) { + Icon( + painterResource(id = R.drawable.ic_delete), + contentDescription = null, + modifier = Modifier.align(Alignment.CenterEnd), + ) + } + }, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + onClick = { onClick(episode) }, + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + // Top Part + EpisodeListItemHeader( + episode = episode, + podcast = podcast, + showPodcastImage = showPodcastImage, + showSummary = showSummary, + modifier = Modifier.padding(bottom = 8.dp), + imageModifier = imageModifier, + ) + + // Bottom Part + EpisodeListItemFooter( + episode = episode, + podcast = podcast, + onQueueEpisode = onQueueEpisode, + ) + } + } + } + when (dismissState.currentValue) { + SwipeToDismissBoxValue.EndToStart -> { + removeFromQueue(episode) + } + + SwipeToDismissBoxValue.StartToEnd -> { + } + + SwipeToDismissBoxValue.Settled -> { + } + } + } +} + +@Composable +private fun EpisodeListItemFooter( + episode: EpisodeInfo, + podcast: PodcastInfo, + onQueueEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Image( + painterResource(id = R.drawable.ic_play_circle), + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false, radius = 24.dp), + ) { /* TODO */ } + .size(48.dp) + .padding(6.dp) + .semantics { role = Role.Button }, + ) + + val duration = episode.duration + Text( + text = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(episode.published), + duration.toMinutes().toInt(), + ) + } + + // Otherwise we just use the date + else -> MediumDateFormatter.format(episode.published) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f), + ) + + IconButton( + onClick = { + onQueueEpisode( + PlayerEpisode( + podcastInfo = podcast, + episodeInfo = episode, + ), + ) + }, + ) { + Icon( + painterResource(id = R.drawable.ic_playlist_add), + contentDescription = stringResource(R.string.cd_add), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + IconButton( + onClick = { /* TODO */ }, + ) { + Icon( + painterResource(id = R.drawable.ic_more_vert), + contentDescription = stringResource(R.string.cd_more), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun EpisodeListItemHeader( + episode: EpisodeInfo, + podcast: PodcastInfo, + showPodcastImage: Boolean, + showSummary: Boolean, + modifier: Modifier = Modifier, + imageModifier: Modifier = Modifier, +) { + Row(modifier = modifier) { + Column( + modifier = + Modifier + .weight(1f) + .padding(end = 16.dp), + ) { + Text( + text = episode.title, + maxLines = 2, + minLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 2.dp), + ) + + if (showSummary) { + HtmlTextContainer(text = episode.summary) { + Text( + text = it, + maxLines = 2, + minLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + ) + } + } else { + Text( + text = podcast.title, + maxLines = 2, + minLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + ) + } + } + if (showPodcastImage) { + EpisodeListItemImage( + podcast = podcast, + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.medium), + imageModifier = imageModifier, + ) + } + } +} + +@Composable +private fun EpisodeListItemImage(podcast: PodcastInfo, modifier: Modifier = Modifier, imageModifier: Modifier = Modifier) { + PodcastImage( + podcastImageUrl = podcast.imageUrl, + contentDescription = null, + modifier = modifier, + imageModifier = imageModifier, + ) +} + +@Preview( + name = "Light Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_NO, +) +@Preview( + name = "Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Composable +private fun EpisodeListItemPreview() { + JetcasterTheme { + EpisodeListItem( + episode = PreviewEpisodes[0], + podcast = PreviewPodcasts[0], + onClick = {}, + onQueueEpisode = {}, + showSummary = true, + ) + } +} + +private val MediumDateFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/Loading.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/Loading.kt new file mode 100644 index 0000000000..4b81f235d9 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/Loading.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.shared + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun Loading(modifier: Modifier = Modifier) { + Surface(modifier = modifier) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator( + Modifier.align(Alignment.Center), + ) + } + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Color.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Color.kt new file mode 100644 index 0000000000..5193851599 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Color.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.theme + +/** + * This is the minimum amount of calculated contrast for a color to be used on top of the + * surface color. These values are defined within the WCAG AA guidelines, and we use a value of + * 3:1 which is the minimum for user-interface components. + */ +const val MinContrastOfPrimaryVsSurface = 3f diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Theme.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Theme.kt new file mode 100644 index 0000000000..31e3dd4cd3 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Theme.kt @@ -0,0 +1,488 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.theme + +import android.os.Build +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.example.jetcaster.designsystem.theme.JetcasterShapes +import com.example.jetcaster.designsystem.theme.JetcasterTypography +import com.example.jetcaster.designsystem.theme.backgroundDark +import com.example.jetcaster.designsystem.theme.backgroundDarkHighContrast +import com.example.jetcaster.designsystem.theme.backgroundDarkMediumContrast +import com.example.jetcaster.designsystem.theme.backgroundLight +import com.example.jetcaster.designsystem.theme.backgroundLightHighContrast +import com.example.jetcaster.designsystem.theme.backgroundLightMediumContrast +import com.example.jetcaster.designsystem.theme.errorContainerDark +import com.example.jetcaster.designsystem.theme.errorContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.errorContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.errorContainerLight +import com.example.jetcaster.designsystem.theme.errorContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.errorContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.errorDark +import com.example.jetcaster.designsystem.theme.errorDarkHighContrast +import com.example.jetcaster.designsystem.theme.errorDarkMediumContrast +import com.example.jetcaster.designsystem.theme.errorLight +import com.example.jetcaster.designsystem.theme.errorLightHighContrast +import com.example.jetcaster.designsystem.theme.errorLightMediumContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryDark +import com.example.jetcaster.designsystem.theme.inversePrimaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryLight +import com.example.jetcaster.designsystem.theme.inversePrimaryLightHighContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseSurfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceLight +import com.example.jetcaster.designsystem.theme.inverseSurfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.onBackgroundDark +import com.example.jetcaster.designsystem.theme.onBackgroundDarkHighContrast +import com.example.jetcaster.designsystem.theme.onBackgroundDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onBackgroundLight +import com.example.jetcaster.designsystem.theme.onBackgroundLightHighContrast +import com.example.jetcaster.designsystem.theme.onBackgroundLightMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerDark +import com.example.jetcaster.designsystem.theme.onErrorContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerLight +import com.example.jetcaster.designsystem.theme.onErrorContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorDark +import com.example.jetcaster.designsystem.theme.onErrorDarkHighContrast +import com.example.jetcaster.designsystem.theme.onErrorDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorLight +import com.example.jetcaster.designsystem.theme.onErrorLightHighContrast +import com.example.jetcaster.designsystem.theme.onErrorLightMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryDark +import com.example.jetcaster.designsystem.theme.onPrimaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryLight +import com.example.jetcaster.designsystem.theme.onPrimaryLightHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryDark +import com.example.jetcaster.designsystem.theme.onSecondaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryLight +import com.example.jetcaster.designsystem.theme.onSecondaryLightHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceDark +import com.example.jetcaster.designsystem.theme.onSurfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceLight +import com.example.jetcaster.designsystem.theme.onSurfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLightHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLightMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryDark +import com.example.jetcaster.designsystem.theme.onTertiaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryLight +import com.example.jetcaster.designsystem.theme.onTertiaryLightHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.outlineDark +import com.example.jetcaster.designsystem.theme.outlineDarkHighContrast +import com.example.jetcaster.designsystem.theme.outlineDarkMediumContrast +import com.example.jetcaster.designsystem.theme.outlineLight +import com.example.jetcaster.designsystem.theme.outlineLightHighContrast +import com.example.jetcaster.designsystem.theme.outlineLightMediumContrast +import com.example.jetcaster.designsystem.theme.outlineVariantDark +import com.example.jetcaster.designsystem.theme.outlineVariantDarkHighContrast +import com.example.jetcaster.designsystem.theme.outlineVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.outlineVariantLight +import com.example.jetcaster.designsystem.theme.outlineVariantLightHighContrast +import com.example.jetcaster.designsystem.theme.outlineVariantLightMediumContrast +import com.example.jetcaster.designsystem.theme.primaryContainerDark +import com.example.jetcaster.designsystem.theme.primaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.primaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.primaryContainerLight +import com.example.jetcaster.designsystem.theme.primaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.primaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.primaryDark +import com.example.jetcaster.designsystem.theme.primaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.primaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.primaryLight +import com.example.jetcaster.designsystem.theme.primaryLightHighContrast +import com.example.jetcaster.designsystem.theme.primaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.scrimDark +import com.example.jetcaster.designsystem.theme.scrimDarkHighContrast +import com.example.jetcaster.designsystem.theme.scrimDarkMediumContrast +import com.example.jetcaster.designsystem.theme.scrimLight +import com.example.jetcaster.designsystem.theme.scrimLightHighContrast +import com.example.jetcaster.designsystem.theme.scrimLightMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerDark +import com.example.jetcaster.designsystem.theme.secondaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerLight +import com.example.jetcaster.designsystem.theme.secondaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryDark +import com.example.jetcaster.designsystem.theme.secondaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.secondaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryLight +import com.example.jetcaster.designsystem.theme.secondaryLightHighContrast +import com.example.jetcaster.designsystem.theme.secondaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightDark +import com.example.jetcaster.designsystem.theme.surfaceBrightDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightLight +import com.example.jetcaster.designsystem.theme.surfaceBrightLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerDark +import com.example.jetcaster.designsystem.theme.surfaceContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLight +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLight +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceDark +import com.example.jetcaster.designsystem.theme.surfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceDimDark +import com.example.jetcaster.designsystem.theme.surfaceDimDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceDimDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceDimLight +import com.example.jetcaster.designsystem.theme.surfaceDimLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceDimLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceLight +import com.example.jetcaster.designsystem.theme.surfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantDark +import com.example.jetcaster.designsystem.theme.surfaceVariantDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantLight +import com.example.jetcaster.designsystem.theme.surfaceVariantLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantLightMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerDark +import com.example.jetcaster.designsystem.theme.tertiaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerLight +import com.example.jetcaster.designsystem.theme.tertiaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryDark +import com.example.jetcaster.designsystem.theme.tertiaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryLight +import com.example.jetcaster.designsystem.theme.tertiaryLightHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryLightMediumContrast + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun JetcasterTheme(dynamicColor: Boolean = false, content: @Composable () -> Unit) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + dynamicDarkColorScheme(context) + } + + else -> darkScheme + } + + MaterialExpressiveTheme( + colorScheme = colorScheme, + motionScheme = MotionScheme.expressive(), + shapes = JetcasterShapes, + typography = JetcasterTypography, + content = content, + ) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/tooling/DevicePreviews.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/tooling/DevicePreviews.kt new file mode 100644 index 0000000000..bb374c5fa8 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/tooling/DevicePreviews.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.tooling + +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview + +@Preview(name = "small-phone", device = Devices.PIXEL_4A) +@Preview(name = "phone", device = Devices.PHONE) +@Preview(name = "landscape", device = "spec:width=640dp,height=360dp,dpi=480") +@Preview(name = "foldable", device = Devices.FOLDABLE) +@Preview(name = "tablet", device = Devices.TABLET) +annotation class DevicePreviews diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Buttons.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Buttons.kt new file mode 100644 index 0000000000..8bc981000e --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Buttons.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.IconToggleButtonColors +import androidx.compose.material3.IconToggleButtonShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ToggleFollowPodcastIconButton(isFollowed: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + IconToggleButton( + checked = isFollowed, + onCheckedChange = { onClick() }, + modifier = modifier, + colors = IconToggleButtonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + disabledContainerColor = MaterialTheme.colorScheme.secondary, + disabledContentColor = MaterialTheme.colorScheme.onSecondary, + checkedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + checkedContentColor = MaterialTheme.colorScheme.secondary, + ), + shapes = IconToggleButtonShapes( + shape = RoundedCornerShape(10.dp), + pressedShape = if (isFollowed) RoundedCornerShape(10.dp) else CircleShape, + checkedShape = CircleShape, + ), + ) { + val transition = updateTransition(targetState = isFollowed, label = "FollowToggle") + val iconRotation by transition.animateFloat( + label = "IconRotation", + transitionSpec = { + if (initialState == targetState) { + snap() + } else { + spring( + dampingRatio = 0.6f, + stiffness = 200f, + ) + } + }, + ) { followed -> + if (followed) 360f else 0f + } + + Icon( + // Animated rotation when follow state changes + painter = when { + isFollowed -> painterResource(id = R.drawable.ic_check) + else -> painterResource(id = R.drawable.ic_add) + }, + contentDescription = when { + isFollowed -> stringResource(R.string.cd_following) + else -> stringResource(R.string.cd_not_following) + }, + modifier = Modifier.rotate(iconRotation), + ) + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Colors.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Colors.kt new file mode 100644 index 0000000000..96d8df128a --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Colors.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import kotlin.math.max +import kotlin.math.min + +fun Color.contrastAgainst(background: Color): Float { + val fg = if (alpha < 1f) compositeOver(background) else this + + val fgLuminance = fg.luminance() + 0.05f + val bgLuminance = background.luminance() + 0.05f + + return max(fgLuminance, bgLuminance) / min(fgLuminance, bgLuminance) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/GradientScrim.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/GradientScrim.kt new file mode 100644 index 0000000000..796447d1bf --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/GradientScrim.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.annotation.FloatRange +import androidx.compose.foundation.background +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** + * Applies a radial gradient scrim in the foreground emanating from the top + * center quarter of the element. + */ +fun Modifier.radialGradientScrim(color: Color): Modifier { + val radialGradient = object : ShaderBrush() { + override fun createShader(size: Size): Shader { + val largerDimension = max(size.height, size.width) + return RadialGradientShader( + center = size.center.copy(y = size.height / 4), + colors = listOf(color, Color.Transparent), + radius = largerDimension / 2, + colorStops = listOf(0f, 0.9f), + ) + } + } + return this.background(radialGradient) +} + +/** + * Draws a vertical gradient scrim in the foreground. + * + * @param color The color of the gradient scrim. + * @param startYPercentage The start y value, in percentage of the layout's height (0f to 1f) + * @param endYPercentage The end y value, in percentage of the layout's height (0f to 1f). This + * value can be smaller than [startYPercentage]. If that is the case, then the gradient direction + * will reverse (decaying downwards, instead of decaying upwards). + * @param decay The exponential decay to apply to the gradient. Defaults to `1.0f` which is + * a linear gradient. + * @param numStops The number of color stops to draw in the gradient. Higher numbers result in + * the higher visual quality at the cost of draw performance. Defaults to `16`. + */ +fun Modifier.verticalGradientScrim( + color: Color, + @FloatRange(from = 0.0, to = 1.0) startYPercentage: Float = 0f, + @FloatRange(from = 0.0, to = 1.0) endYPercentage: Float = 1f, + decay: Float = 1.0f, + numStops: Int = 16, +) = this then VerticalGradientElement(color, startYPercentage, endYPercentage, decay, numStops) + +private data class VerticalGradientElement( + var color: Color, + var startYPercentage: Float = 0f, + var endYPercentage: Float = 1f, + var decay: Float = 1.0f, + var numStops: Int = 16, +) : ModifierNodeElement() { + fun createOnDraw(): DrawScope.() -> Unit { + val colors = if (decay != 1f) { + // If we have a non-linear decay, we need to create the color gradient steps + // manually + val baseAlpha = color.alpha + List(numStops) { i -> + val x = i * 1f / (numStops - 1) + val opacity = x.pow(decay) + color.copy(alpha = baseAlpha * opacity) + } + } else { + // If we have a linear decay, we just create a simple list of start + end colors + listOf(color.copy(alpha = 0f), color) + } + + val brush = + // Reverse the gradient if decaying downwards + Brush.verticalGradient( + colors = if (startYPercentage < endYPercentage) colors else colors.reversed(), + ) + + return { + val topLeft = Offset(0f, size.height * min(startYPercentage, endYPercentage)) + val bottomRight = + Offset(size.width, size.height * max(startYPercentage, endYPercentage)) + + drawRect( + topLeft = topLeft, + size = Rect(topLeft, bottomRight).size, + brush = brush, + ) + } + } + + override fun create() = VerticalGradientModifier(createOnDraw()) + + override fun update(node: VerticalGradientModifier) { + node.onDraw = createOnDraw() + } + + /** + * Allow this custom modifier to be inspected in the layout inspector + **/ + override fun InspectorInfo.inspectableProperties() { + name = "verticalGradientScrim" + properties["color"] = color + properties["startYPercentage"] = startYPercentage + properties["endYPercentage"] = endYPercentage + properties["decay"] = decay + properties["numStops"] = numStops + } +} + +private class VerticalGradientModifier(var onDraw: DrawScope.() -> Unit) : + Modifier.Node(), + DrawModifierNode { + + override fun ContentDrawScope.draw() { + onDraw() + drawContent() + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt new file mode 100644 index 0000000000..bf4e357861 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable + +/** + * An item that occupies the entire width. + */ +fun LazyGridScope.fullWidthItem(key: Any? = null, contentType: Any? = null, content: @Composable LazyGridItemScope.() -> Unit) = item( + span = { GridItemSpan(this.maxLineSpan) }, + key = key, + contentType = contentType, + content = content, +) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/PluralResources.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/PluralResources.kt similarity index 100% rename from Jetcaster/app/src/main/java/com/example/jetcaster/util/PluralResources.kt rename to Jetcaster/mobile/src/main/java/com/example/jetcaster/util/PluralResources.kt diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/ViewModel.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/ViewModel.kt new file mode 100644 index 0000000000..1d2e5d1685 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/ViewModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory + +/** + * Returns a [ViewModelProvider.Factory] which will return the result of [create] when it's + * [ViewModelProvider.Factory.create] function is called. + * + * If the created [ViewModel] does not match the requested class, an [IllegalArgumentException] + * exception is thrown. + */ +inline fun viewModelProviderFactoryOf(crossinline create: () -> VM): ViewModelProvider.Factory = viewModelFactory { + initializer { + create() + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowInfoUtil.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowInfoUtil.kt new file mode 100644 index 0000000000..1d7c2e7a46 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowInfoUtil.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.window.layout.FoldingFeature +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +fun isTableTopPosture(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.HALF_OPENED && + foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL +} + +@OptIn(ExperimentalContracts::class) +fun isBookPosture(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.HALF_OPENED && + foldFeature.orientation == FoldingFeature.Orientation.VERTICAL +} + +@OptIn(ExperimentalContracts::class) +fun isSeparatingPosture(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt new file mode 100644 index 0000000000..c08593c29a --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.window.core.layout.WindowSizeClass + +/** + * Returns true if the width or height size classes are compact. + */ +val WindowSizeClass.isCompact: Boolean + get() = !isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) || + !isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND) diff --git a/Jetcaster/app/src/main/res/drawable-nodpi/ic_text_logo.xml b/Jetcaster/mobile/src/main/res/drawable-nodpi/ic_text_logo.xml similarity index 100% rename from Jetcaster/app/src/main/res/drawable-nodpi/ic_text_logo.xml rename to Jetcaster/mobile/src/main/res/drawable-nodpi/ic_text_logo.xml diff --git a/Jetcaster/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Jetcaster/mobile/src/main/res/drawable-v26/ic_launcher_foreground.xml similarity index 100% rename from Jetcaster/app/src/main/res/drawable-v26/ic_launcher_foreground.xml rename to Jetcaster/mobile/src/main/res/drawable-v26/ic_launcher_foreground.xml diff --git a/Jetcaster/mobile/src/main/res/drawable/genres.xml b/Jetcaster/mobile/src/main/res/drawable/genres.xml new file mode 100644 index 0000000000..1018f99bf5 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/genres.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_account_circle.xml b/Jetcaster/mobile/src/main/res/drawable/ic_account_circle.xml new file mode 100644 index 0000000000..67542bfa21 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_account_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_add.xml b/Jetcaster/mobile/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000000..2fcde96c96 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_arrow_back.xml b/Jetcaster/mobile/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000000..800941dba5 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_check.xml b/Jetcaster/mobile/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000000..6f1fcce828 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_delete.xml b/Jetcaster/mobile/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000000..f538c1cc13 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_forward_10.xml b/Jetcaster/mobile/src/main/res/drawable/ic_forward_10.xml new file mode 100644 index 0000000000..7526b1a6b5 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_forward_10.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_launcher_background.xml b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..7f2643db2d --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_launcher_foreground.xml b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..c19b699858 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_launcher_monochrome.xml b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..e71686aef8 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_library_music.xml b/Jetcaster/mobile/src/main/res/drawable/ic_library_music.xml new file mode 100644 index 0000000000..790e3f6628 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_library_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_logo.xml b/Jetcaster/mobile/src/main/res/drawable/ic_logo.xml new file mode 100644 index 0000000000..8d00d29968 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_more_vert.xml b/Jetcaster/mobile/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000000..59400ec977 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_notifications.xml b/Jetcaster/mobile/src/main/res/drawable/ic_notifications.xml new file mode 100644 index 0000000000..cd47b10ae1 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_notifications.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_notifications_active.xml b/Jetcaster/mobile/src/main/res/drawable/ic_notifications_active.xml new file mode 100644 index 0000000000..771bf6535d --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_notifications_active.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_pause.xml b/Jetcaster/mobile/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000000..eef07ec5e7 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,10 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_play_arrow.xml b/Jetcaster/mobile/src/main/res/drawable/ic_play_arrow.xml new file mode 100644 index 0000000000..a770230e88 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_play_arrow.xml @@ -0,0 +1,10 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_play_circle.xml b/Jetcaster/mobile/src/main/res/drawable/ic_play_circle.xml new file mode 100644 index 0000000000..6a4add8dda --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_play_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_playlist_add.xml b/Jetcaster/mobile/src/main/res/drawable/ic_playlist_add.xml new file mode 100644 index 0000000000..5022c69f3c --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_playlist_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_replay_10.xml b/Jetcaster/mobile/src/main/res/drawable/ic_replay_10.xml new file mode 100644 index 0000000000..f9826d9a05 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_replay_10.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_search.xml b/Jetcaster/mobile/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000000..20c7b4e734 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_skip_next.xml b/Jetcaster/mobile/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 0000000000..09407f6551 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_skip_previous.xml b/Jetcaster/mobile/src/main/res/drawable/ic_skip_previous.xml new file mode 100644 index 0000000000..f494ae6538 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_skip_previous.xml @@ -0,0 +1,10 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_video_library.xml b/Jetcaster/mobile/src/main/res/drawable/ic_video_library.xml new file mode 100644 index 0000000000..54ee520a83 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_video_library.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..96e4ade2ed --- /dev/null +++ b/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..96e4ade2ed --- /dev/null +++ b/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..1e97e1b9ec Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..1e97e1b9ec Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..821e87fac3 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..821e87fac3 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..347493f918 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..347493f918 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..463f54c5d2 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..463f54c5d2 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..50721da443 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..50721da443 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/app/src/main/res/values/colors.xml b/Jetcaster/mobile/src/main/res/values/colors.xml similarity index 100% rename from Jetcaster/app/src/main/res/values/colors.xml rename to Jetcaster/mobile/src/main/res/values/colors.xml diff --git a/Jetcaster/mobile/src/main/res/values/strings.xml b/Jetcaster/mobile/src/main/res/values/strings.xml new file mode 100644 index 0000000000..1b8a07345b --- /dev/null +++ b/Jetcaster/mobile/src/main/res/values/strings.xml @@ -0,0 +1,72 @@ + + + + Jetcaster + + Connection error + Unable to fetch podcasts feeds.\nCheck your internet connection and try again. + Retry + + Your podcasts + Latest episodes + + Your library + Discover + + Discover + Library + Discover Tab Icon + Library Tab Icon + + Updated a while ago + + Updated %d week ago + Updated %d weeks ago + + + Updated yesterday + Updated %d days ago + + Updated today + + %1$s • %2$d mins + + Account + Add + Back + Follow + Following + Forward 10 seconds + More + Not following + Pause + Play + Replay 10 seconds + Search + Selected category + Skip next + Skip previous + Unfollow + Episode added to your queue + Podcast image + Subscribe + Subscribed + see more + Search for a podcast + An error has occurred. + + diff --git a/Jetcaster/mobile/src/main/res/values/themes.xml b/Jetcaster/mobile/src/main/res/values/themes.xml new file mode 100644 index 0000000000..79be4dfdf6 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/values/themes.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/Jetcaster/settings.gradle b/Jetcaster/settings.gradle deleted file mode 100644 index 271819e4b2..0000000000 --- a/Jetcaster/settings.gradle +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -include ':app' -rootProject.name = "Jetcaster" \ No newline at end of file diff --git a/Jetcaster/settings.gradle.kts b/Jetcaster/settings.gradle.kts new file mode 100644 index 0000000000..2eee9e75ae --- /dev/null +++ b/Jetcaster/settings.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +val snapshotVersion : String? = System.getenv("COMPOSE_SNAPSHOT_ID") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + snapshotVersion?.let { + println("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") + maven { url = uri("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") } + } + + google() + mavenCentral() + } +} +rootProject.name = "Jetcaster" +include( + ":mobile", + ":core:data", + ":core:data-testing", + ":core:domain", + ":core:domain-testing", + ":core:designsystem", + ":tv", + ":wear", + ":glancewidget" +) +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") diff --git a/Jetcaster/stability_config.conf b/Jetcaster/stability_config.conf new file mode 100644 index 0000000000..a2593c14d3 --- /dev/null +++ b/Jetcaster/stability_config.conf @@ -0,0 +1,2 @@ +java.time.* +kotlin.collections.* diff --git a/Jetcaster/tv/.gitignore b/Jetcaster/tv/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/tv/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/tv/build.gradle.kts b/Jetcaster/tv/build.gradle.kts new file mode 100644 index 0000000000..933f82e672 --- /dev/null +++ b/Jetcaster/tv/build.gradle.kts @@ -0,0 +1,137 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) + alias(libs.plugins.compose) +} + +android { + namespace = "com.example.jetcaster.tv" + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + + defaultConfig { + applicationId = "com.example.jetcaster" + minSdk = + libs.versions.minSdk + .get() + .toInt() + targetSdk = + libs.versions.targetSdk + .get() + .toInt() + versionCode = 1 + versionName = "1.0" + vectorDrawables { + useSupportLibrary = true + } + } + signingConfigs { + // Important: change the keystore for a production deployment + val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore") + val localKeystore = rootProject.file("debug_2.keystore") + val hasKeyInfo = userKeystore.exists() + create("release") { + // get from env variables + storeFile = if (hasKeyInfo) userKeystore else localKeystore + storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password") + keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias") + keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password") + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging { + resources { + // The Rome library JARs embed some internal utils libraries in nested JARs. + // We don't need them so we exclude them in the final package. + excludes += "/*.jar" + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + metricsDestination = layout.buildDirectory.dir("compose_compiler") + stabilityConfigurationFiles = listOf(rootProject.layout.projectDirectory.file("stability_config.conf")) + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.tv.material) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(projects.core.data) + implementation(projects.core.designsystem) + implementation(projects.core.domain) + + // Media3 + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.ui.compose) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + coreLibraryDesugaring(libs.core.jdk.desugaring) +} diff --git a/Jetcaster/tv/proguard-rules.pro b/Jetcaster/tv/proguard-rules.pro new file mode 100644 index 0000000000..8bba6b5e9c --- /dev/null +++ b/Jetcaster/tv/proguard-rules.pro @@ -0,0 +1,52 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses + +# Rome reflectively loads classes referenced in com/rometools/rome/rome.properties. +-adaptresourcefilecontents com/rometools/rome/rome.properties +-keep class * implements com.rometools.rome.feed.synd.Converter +-keep class * implements com.rometools.rome.io.ModuleParser +-keep class * implements com.rometools.rome.io.WireFeedParser + +# Disable warnings for missing classes from OkHttp. +-dontwarn org.conscrypt.ConscryptHostnameVerifier + +# Disable warnings for missing classes from JDOM. +-dontwarn org.jaxen.DefaultNavigator +-dontwarn org.jaxen.NamespaceContext +-dontwarn org.jaxen.VariableContext + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE + +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } \ No newline at end of file diff --git a/Jetcaster/tv/src/main/AndroidManifest.xml b/Jetcaster/tv/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..3ab2d935a4 --- /dev/null +++ b/Jetcaster/tv/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt new file mode 100644 index 0000000000..0d85c0b841 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class JetCasterTvApp : Application() diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/MainActivity.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/MainActivity.kt new file mode 100644 index 0000000000..9edf820c07 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/MainActivity.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.tv.material3.Surface +import com.example.jetcaster.tv.ui.JetcasterApp +import com.example.jetcaster.tv.ui.theme.JetcasterTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + // TV is hardcoded to dark mode to match TV ui + JetcasterTheme(isInDarkTheme = true) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RectangleShape, + ) { + JetcasterApp() + } + } + } + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/CategoryInfoList.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/CategoryInfoList.kt new file mode 100644 index 0000000000..95b1d595b1 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/CategoryInfoList.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.asExternalModel + +@Immutable +data class CategoryInfoList(val member: List) : List by member { + + fun intoCategoryList(): List { + return map(CategoryInfo::intoCategory) + } + + companion object { + fun from(list: List): CategoryInfoList { + val member = list.map(Category::asExternalModel) + return CategoryInfoList(member) + } + } +} + +private fun CategoryInfo.intoCategory(): Category { + return Category(id, name) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/CategorySelection.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/CategorySelection.kt new file mode 100644 index 0000000000..a0a4df910e --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/CategorySelection.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.model.CategoryInfo + +data class CategorySelection(val categoryInfo: CategoryInfo, val isSelected: Boolean = false) + +@Immutable +data class CategorySelectionList(val member: List) : List by member diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt new file mode 100644 index 0000000000..44f819252b --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.player.model.PlayerEpisode + +@Immutable +data class EpisodeList(val member: List) : List by member diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt new file mode 100644 index 0000000000..b68b8e7025 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.model + +import com.example.jetcaster.core.model.PodcastInfo + +typealias PodcastList = List diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt new file mode 100644 index 0000000000..c9ab65d688 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -0,0 +1,242 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.tv.material3.DrawerValue +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.NavigationDrawer +import androidx.tv.material3.NavigationDrawerItem +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.ui.discover.DiscoverScreen +import com.example.jetcaster.tv.ui.episode.EpisodeScreen +import com.example.jetcaster.tv.ui.library.LibraryScreen +import com.example.jetcaster.tv.ui.player.PlayerScreen +import com.example.jetcaster.tv.ui.podcast.PodcastDetailsScreen +import com.example.jetcaster.tv.ui.profile.ProfileScreen +import com.example.jetcaster.tv.ui.search.SearchScreen +import com.example.jetcaster.tv.ui.settings.SettingsScreen +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppState()) { + Route(jetcasterAppState = jetcasterAppState) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun GlobalNavigationContainer( + jetcasterAppState: JetcasterAppState, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val (discover, library) = remember { FocusRequester.createRefs() } + val currentRoute + by jetcasterAppState.currentRouteFlow.collectAsStateWithLifecycle(initialValue = null) + + NavigationDrawer( + drawerContent = { + val isClosed = it == DrawerValue.Closed + Column( + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.drawer.intoPaddingValues()) + .focusProperties { + onEnter = { + when (currentRoute) { + Screen.Discover.route -> discover + Screen.Library.route -> library + else -> FocusRequester.Default + } + } + } + .focusGroup(), + ) { + NavigationDrawerItem( + selected = isClosed && currentRoute == Screen.Profile.route, + onClick = jetcasterAppState::navigateToProfile, + leadingContent = { Icon(painterResource(id = R.drawable.ic_person), contentDescription = null) }, + ) { + Column { + Text(text = "Name") + Text( + text = "Switch Account", + style = MaterialTheme.typography.labelSmall, + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + NavigationDrawerItem( + selected = isClosed && currentRoute == Screen.Search.route, + onClick = jetcasterAppState::navigateToSearch, + leadingContent = { + Icon( + painterResource(id = R.drawable.ic_search), + contentDescription = null, + ) + }, + ) { + Text(text = "Search") + } + NavigationDrawerItem( + selected = isClosed && currentRoute == Screen.Discover.route, + onClick = jetcasterAppState::navigateToDiscover, + leadingContent = { + Icon( + painterResource(id = R.drawable.ic_home), + contentDescription = null, + ) + }, + modifier = Modifier.focusRequester(discover), + ) { + Text(text = "Discover") + } + NavigationDrawerItem( + selected = isClosed && currentRoute == Screen.Library.route, + onClick = jetcasterAppState::navigateToLibrary, + leadingContent = { + Icon( + painterResource(id = R.drawable.ic_video_library), + contentDescription = null, + ) + }, + modifier = Modifier.focusRequester(library), + ) { + Text(text = "Library") + } + Spacer(modifier = Modifier.weight(1f)) + NavigationDrawerItem( + selected = isClosed && currentRoute == Screen.Settings.route, + onClick = jetcasterAppState::navigateToSettings, + leadingContent = { Icon(painterResource(id = R.drawable.ic_settings), contentDescription = null) }, + ) { + Text(text = "Settings") + } + } + }, + content = content, + modifier = modifier, + ) +} + +@Composable +private fun Route(jetcasterAppState: JetcasterAppState) { + NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) { + composable(Screen.Discover.route) { + GlobalNavigationContainer(jetcasterAppState = jetcasterAppState) { + DiscoverScreen( + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.uri) + }, + playEpisode = { + jetcasterAppState.playEpisode() + }, + modifier = Modifier.fillMaxSize(), + ) + } + } + + composable(Screen.Library.route) { + GlobalNavigationContainer(jetcasterAppState = jetcasterAppState) { + LibraryScreen( + navigateToDiscover = jetcasterAppState::navigateToDiscover, + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.uri) + }, + playEpisode = { + jetcasterAppState.playEpisode() + }, + modifier = Modifier.fillMaxSize(), + ) + } + } + + composable(Screen.Search.route) { + SearchScreen( + onPodcastSelected = { + jetcasterAppState.showPodcastDetails(it.uri) + }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + .fillMaxSize(), + ) + } + + composable(Screen.Podcast.route) { + PodcastDetailsScreen( + backToHomeScreen = jetcasterAppState::navigateToDiscover, + playEpisode = { + jetcasterAppState.playEpisode() + }, + showEpisodeDetails = { jetcasterAppState.showEpisodeDetails(it.uri) }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) + .fillMaxSize(), + ) + } + + composable(Screen.Episode.route) { + EpisodeScreen( + playEpisode = { + jetcasterAppState.playEpisode() + }, + backToHome = jetcasterAppState::backToHome, + ) + } + + composable(Screen.Player.route) { + PlayerScreen( + backToHome = jetcasterAppState::backToHome, + modifier = Modifier.fillMaxSize(), + showDetails = jetcasterAppState::showEpisodeDetails, + ) + } + + composable(Screen.Profile.route) { + ProfileScreen( + modifier = Modifier + .fillMaxSize() + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()), + ) + } + + composable(Screen.Settings.route) { + SettingsScreen( + modifier = Modifier + .fillMaxSize() + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()), + ) + } + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt new file mode 100644 index 0000000000..05f6001714 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import com.example.jetcaster.core.player.model.PlayerEpisode +import kotlinx.coroutines.flow.map + +class JetcasterAppState(val navHostController: NavHostController) { + + val currentRouteFlow = navHostController.currentBackStackEntryFlow.map { + it.destination.route + } + + private fun navigate(screen: Screen) { + navHostController.navigate(screen.route) + } + + fun navigateToDiscover() { + navigate(Screen.Discover) + } + + fun navigateToLibrary() { + navigate(Screen.Library) + } + + fun navigateToProfile() { + navigate(Screen.Profile) + } + + fun navigateToSearch() { + navigate(Screen.Search) + } + + fun navigateToSettings() { + navigate(Screen.Settings) + } + + fun showPodcastDetails(podcastUri: String) { + val encodedUrL = Uri.encode(podcastUri) + val screen = Screen.Podcast(encodedUrL) + navigate(screen) + } + + fun showEpisodeDetails(episodeUri: String) { + val encodeUrl = Uri.encode(episodeUri) + val screen = Screen.Episode(encodeUrl) + navigate(screen) + } + + fun showEpisodeDetails(playerEpisode: PlayerEpisode) { + showEpisodeDetails(playerEpisode.uri) + } + + fun playEpisode() { + navigate(Screen.Player) + } + + fun backToHome() { + navHostController.popBackStack() + navigateToDiscover() + } +} + +@Composable +fun rememberJetcasterAppState(navHostController: NavHostController = rememberNavController()) = remember(navHostController) { + JetcasterAppState(navHostController) +} + +sealed interface Screen { + val route: String + + data object Discover : Screen { + override val route = "/discover" + } + + data object Library : Screen { + override val route = "/library" + } + + data object Search : Screen { + override val route = "/search" + } + + data object Profile : Screen { + override val route = "/profile" + } + + data object Settings : Screen { + override val route: String = "settings" + } + + data class Podcast(private val podcastUri: String) : Screen { + override val route = "$ROOT/$podcastUri" + + companion object : Screen { + private const val ROOT = "/podcast" + const val PARAMETER_NAME = "podcastUri" + override val route = "$ROOT/{$PARAMETER_NAME}" + } + } + + data class Episode(private val episodeUri: String) : Screen { + + override val route: String = "$ROOT/$episodeUri" + + companion object : Screen { + private const val ROOT = "/episode" + const val PARAMETER_NAME = "episodeUri" + override val route = "$ROOT/{$PARAMETER_NAME}" + } + } + + data object Player : Screen { + override val route = "player" + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt new file mode 100644 index 0000000000..9a9cb17eae --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.ImageBackgroundRadialGradientScrim + +@Composable +internal fun BackgroundContainer( + playerEpisode: PlayerEpisode, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, + content: @Composable BoxScope.() -> Unit, +) = BackgroundContainer( + imageUrl = playerEpisode.podcastImageUrl, + modifier, + contentAlignment, + content, +) + +@Composable +internal fun BackgroundContainer( + podcastInfo: PodcastInfo, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, + content: @Composable BoxScope.() -> Unit, +) = BackgroundContainer(imageUrl = podcastInfo.imageUrl, modifier, contentAlignment, content) + +@Composable +internal fun BackgroundContainer( + imageUrl: String, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, + content: @Composable BoxScope.() -> Unit, +) { + Box(modifier = modifier, contentAlignment = contentAlignment) { + Background(imageUrl = imageUrl, modifier = Modifier.fillMaxSize()) + content() + } +} + +@Composable +private fun Background(imageUrl: String, modifier: Modifier = Modifier) { + ImageBackgroundRadialGradientScrim( + url = imageUrl, + colors = listOf(Color.Black, Color.Transparent), + modifier = modifier, + ) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt new file mode 100644 index 0000000000..6bf564b76e --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.ButtonScale +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import com.example.jetcaster.tv.R + +@Composable +internal fun PlayButton(onClick: () -> Unit, modifier: Modifier = Modifier, scale: ButtonScale = ButtonDefaults.scale()) = ButtonWithIcon( + iconId = R.drawable.ic_play_arrow, + label = stringResource(R.string.label_play), + onClick = onClick, + modifier = modifier, + scale = scale, +) + +@Composable +internal fun EnqueueButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + painterResource(id = R.drawable.ic_playlist_add), + contentDescription = stringResource(R.string.label_add_playlist), + ) + } +} + +@Composable +internal fun InfoButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + painterResource(id = R.drawable.ic_info), + contentDescription = stringResource(R.string.label_info), + ) + } +} + +@Composable +internal fun PreviousButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + painterResource(id = R.drawable.ic_skip_previous), + contentDescription = stringResource(R.string.label_previous_episode), + ) + } +} + +@Composable +internal fun NextButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + painterResource(id = R.drawable.ic_skip_next), + contentDescription = stringResource(R.string.label_next_episode), + ) + } +} + +@Composable +internal fun PlayPauseButton(isPlaying: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val (icon, description) = if (isPlaying) { + painterResource(id = R.drawable.ic_pause) to stringResource(R.string.label_pause) + } else { + painterResource(id = R.drawable.ic_play_arrow) to stringResource(R.string.label_play) + } + IconButton(onClick = onClick, modifier = modifier) { + Icon(icon, description, modifier = Modifier.size(48.dp)) + } +} + +@Composable +internal fun RewindButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + painterResource(id = R.drawable.ic_replay_10), + contentDescription = stringResource(R.string.label_rewind), + ) + } +} + +@Composable +internal fun SkipButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + painterResource(id = R.drawable.ic_forward_10), + contentDescription = stringResource(R.string.label_skip), + ) + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt new file mode 100644 index 0000000000..ce4314810a --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.ButtonScale +import androidx.tv.material3.Icon +import androidx.tv.material3.Text + +@Composable +internal fun ButtonWithIcon( + label: String, + @DrawableRes iconId: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + scale: ButtonScale = ButtonDefaults.scale(), +) { + Button(onClick = onClick, modifier = modifier, scale = scale) { + Icon( + painterResource(id = iconId), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = label) + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt new file mode 100644 index 0000000000..d54bc76229 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +internal fun Catalog( + podcastList: PodcastList, + latestEpisodeList: EpisodeList, + onPodcastSelected: (PodcastInfo) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + header: (@Composable () -> Unit)? = null, +) { + LazyColumn( + modifier = modifier, + contentPadding = JetcasterAppDefaults.overScanMargin.catalog.intoPaddingValues(), + verticalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gap.section), + state = state, + ) { + if (header != null) { + item { header() } + } + item { + PodcastSection( + podcastList = podcastList, + onPodcastSelected = onPodcastSelected, + title = stringResource(R.string.label_podcast), + ) + } + item { + LatestEpisodeSection( + episodeList = latestEpisodeList, + onEpisodeSelected = onEpisodeSelected, + title = stringResource(R.string.label_latest_episode), + ) + } + } +} + +@Composable +private fun PodcastSection( + podcastList: PodcastList, + onPodcastSelected: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, + title: String? = null, +) { + Section( + title = title, + modifier = modifier, + ) { + PodcastRow( + podcastList = podcastList, + onPodcastSelected = onPodcastSelected, + ) + } +} + +@Composable +private fun LatestEpisodeSection( + episodeList: EpisodeList, + onEpisodeSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + title: String? = null, +) { + Section( + modifier = modifier, + title = title, + ) { + EpisodeRow( + playerEpisodeList = episodeList, + onSelected = onEpisodeSelected, + ) + } +} + +@Composable +private fun Section( + modifier: Modifier = Modifier, + title: String? = null, + style: TextStyle = MaterialTheme.typography.headlineMedium, + content: @Composable () -> Unit, +) { + Column(modifier) { + if (title != null) { + Text( + text = title, + style = style, + modifier = Modifier.padding(JetcasterAppDefaults.padding.sectionTitle), + ) + } + content() + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PodcastRow( + podcastList: PodcastList, + onPodcastSelected: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = JetcasterAppDefaults.padding.podcastRowContentPadding, + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), +) { + val (focusRequester, firstItem) = remember(podcastList) { FocusRequester.createRefs() } + + LazyRow( + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + modifier = modifier + .focusRequester(focusRequester) + .focusProperties { + onExit = { + focusRequester.saveFocusedChild() + FocusRequester.Default + } + onEnter = { + if (focusRequester.restoreFocusedChild()) { + FocusRequester.Cancel + } else { + firstItem + } + } + }, + ) { + itemsIndexed(podcastList) { index, podcastInfo -> + val cardModifier = if (index == 0) { + Modifier.focusRequester(firstItem) + } else { + Modifier + } + PodcastCard( + podcastInfo = podcastInfo, + onClick = { onPodcastSelected(podcastInfo) }, + modifier = cardModifier.width(JetcasterAppDefaults.cardWidth.medium), + ) + } + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt new file mode 100644 index 0000000000..2193379a0c --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.CardScale +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.WideCardContainer +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +internal fun EpisodeCard( + playerEpisode: PlayerEpisode, + onClick: () -> Unit, + modifier: Modifier = Modifier, + cardSize: DpSize = JetcasterAppDefaults.thumbnailSize.episode, +) { + WideCardContainer( + imageCard = { + EpisodeThumbnail(playerEpisode, onClick = onClick, modifier = Modifier.size(cardSize)) + }, + title = { + EpisodeMetaData( + playerEpisode = playerEpisode, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .width(JetcasterAppDefaults.cardWidth.small * 2), + ) + }, + modifier = modifier, + ) +} + +@Composable +private fun EpisodeThumbnail( + playerEpisode: PlayerEpisode, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + Card( + onClick = onClick, + interactionSource = interactionSource, + scale = CardScale.None, + shape = CardDefaults.shape(RoundedCornerShape(12.dp)), + modifier = modifier, + ) { + Thumbnail(episode = playerEpisode, size = JetcasterAppDefaults.thumbnailSize.episode) + } +} + +@Composable +private fun EpisodeMetaData(playerEpisode: PlayerEpisode, modifier: Modifier = Modifier) { + val duration = playerEpisode.duration + Column(modifier = modifier) { + Text( + text = playerEpisode.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text(text = playerEpisode.podcastName, style = MaterialTheme.typography.bodySmall) + if (duration != null) { + Spacer( + modifier = Modifier.height(JetcasterAppDefaults.gap.podcastRow * 0.8f), + ) + EpisodeDataAndDuration(offsetDateTime = playerEpisode.published, duration = duration) + } + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt new file mode 100644 index 0000000000..97684ada0b --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R +import java.time.Duration +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +private val MediumDateFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +} + +@Composable +internal fun EpisodeDataAndDuration( + offsetDateTime: OffsetDateTime, + duration: Duration, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodySmall, +) { + Text( + text = stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(offsetDateTime), + duration.toMinutes().toInt(), + ), + style = style, + modifier = modifier, + ) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt new file mode 100644 index 0000000000..46e108a6fd --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDetails.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +internal fun EpisodeDetails( + playerEpisode: PlayerEpisode, + modifier: Modifier = Modifier, + controls: (@Composable () -> Unit)? = null, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + content: @Composable ColumnScope.() -> Unit, +) { + TwoColumn( + modifier = modifier, + first = { + Thumbnail( + playerEpisode, + size = JetcasterAppDefaults.thumbnailSize.episodeDetails, + ) + }, + second = { + Column( + modifier = modifier, + verticalArrangement = verticalArrangement, + ) { + EpisodeAuthor(playerEpisode = playerEpisode) + EpisodeTitle(playerEpisode = playerEpisode) + content() + if (controls != null) { + controls() + } + } + }, + ) +} + +@Composable +internal fun EpisodeAuthor( + playerEpisode: PlayerEpisode, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodySmall, +) { + Text(text = playerEpisode.author, modifier = modifier, style = style) +} + +@Composable +internal fun EpisodeTitle( + playerEpisode: PlayerEpisode, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.headlineLarge, +) { + Text(text = playerEpisode.title, modifier = modifier, style = style) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt new file mode 100644 index 0000000000..602cd3b69e --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeRow.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun EpisodeRow( + playerEpisodeList: EpisodeList, + onSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + contentPadding: PaddingValues = JetcasterAppDefaults.padding.episodeRowContentPadding, + focusRequester: FocusRequester = remember { FocusRequester() }, + lazyListState: LazyListState = remember(playerEpisodeList) { LazyListState() }, +) { + val firstItem = remember { FocusRequester() } + var previousEpisodeListHash by remember { mutableIntStateOf(playerEpisodeList.hashCode()) } + val isSameList = previousEpisodeListHash == playerEpisodeList.hashCode() + + LazyRow( + state = lazyListState, + modifier = Modifier + .focusRequester(focusRequester) + .focusProperties { + onEnter = { + when { + lazyListState.layoutInfo.visibleItemsInfo.isEmpty() -> FocusRequester.Cancel + isSameList && focusRequester.restoreFocusedChild() -> FocusRequester.Cancel + else -> firstItem + } + } + onExit = { + previousEpisodeListHash = playerEpisodeList.hashCode() + focusRequester.saveFocusedChild() + FocusRequester.Default + } + } + .then(modifier), + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + ) { + itemsIndexed(playerEpisodeList) { index, item -> + val cardModifier = if (index == 0) { + Modifier.focusRequester(firstItem) + } else { + Modifier + } + EpisodeCard( + playerEpisode = item, + onClick = { onSelected(item) }, + modifier = cardModifier, + ) + } + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt new file mode 100644 index 0000000000..961f0099ca --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun ErrorState(backToHome: () -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() }) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column { + Text( + text = stringResource(R.string.display_error_state), + style = MaterialTheme.typography.displayMedium, + ) + Button( + onClick = backToHome, + modifier + .padding(top = JetcasterAppDefaults.gap.podcastRow) + .focusRequester(focusRequester), + ) { + Text(text = stringResource(R.string.label_back_to_home)) + } + } + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt new file mode 100644 index 0000000000..ff77ba006e --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt @@ -0,0 +1,213 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateValue +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.max + +@Composable +fun Loading( + modifier: Modifier = Modifier, + message: String = stringResource(id = R.string.message_loading), + contentAlignment: Alignment = Alignment.Center, + style: TextStyle = MaterialTheme.typography.displaySmall, +) { + Box( + modifier = modifier, + contentAlignment = contentAlignment, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.default), + ) { + CircularProgressIndicator() + Text(text = message, style = style) + } + } +} + +@Composable +fun CircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + strokeWidth: Dp = 4.dp, + trackColor: Color = MaterialTheme.colorScheme.surface, + strokeCap: StrokeCap = StrokeCap.Round, +) { + val transition = rememberInfiniteTransition("loading") + + val stroke = with(LocalDensity.current) { + Stroke(width = strokeWidth.toPx(), cap = strokeCap) + } + + val currentRotation = transition.animateValue( + 0, + RotationsPerCycle, + Int.VectorConverter, + infiniteRepeatable( + animation = tween( + durationMillis = RotationDuration * RotationsPerCycle, + easing = LinearEasing, + ), + ), + "loading_current_rotation", + ) + // How far forward (degrees) the base point should be from the start point + val baseRotation = transition.animateFloat( + 0f, + BaseRotationAngle, + infiniteRepeatable( + animation = tween( + durationMillis = RotationDuration, + easing = LinearEasing, + ), + ), + "loading_base_rotation_angle", + ) + // How far forward (degrees) both the head and tail should be from the base point + val endAngle = transition.animateFloat( + 0f, + JumpRotationAngle, + infiniteRepeatable( + animation = keyframes { + durationMillis = HeadAndTailAnimationDuration + HeadAndTailDelayDuration + 0f at 0 using CircularEasing + JumpRotationAngle at HeadAndTailAnimationDuration + }, + ), + "loading_end_rotation_angle", + ) + val startAngle = transition.animateFloat( + 0f, + JumpRotationAngle, + infiniteRepeatable( + animation = keyframes { + durationMillis = HeadAndTailAnimationDuration + HeadAndTailDelayDuration + 0f at HeadAndTailDelayDuration using CircularEasing + JumpRotationAngle at durationMillis + }, + ), + "loading_start_angle", + ) + + Canvas( + modifier + .progressSemantics() + .size(CircularIndicatorDiameter), + ) { + drawCircularIndicatorTrack(trackColor, stroke) + + val currentRotationAngleOffset = (currentRotation.value * RotationAngleOffset) % 360f + + // How long a line to draw using the start angle as a reference point + val sweep = abs(endAngle.value - startAngle.value) + + // Offset by the constant offset and the per rotation offset + val offset = StartAngleOffset + currentRotationAngleOffset + baseRotation.value + drawIndeterminateCircularIndicator( + startAngle.value + offset, + strokeWidth, + sweep, + color, + stroke, + ) + } +} + +private fun DrawScope.drawCircularIndicator(startAngle: Float, sweep: Float, color: Color, stroke: Stroke) { + // To draw this circle we need a rect with edges that line up with the midpoint of the stroke. + // To do this we need to remove half the stroke width from the total diameter for both sides. + val diameterOffset = stroke.width / 2 + val arcDimen = size.width - 2 * diameterOffset + drawArc( + color = color, + startAngle = startAngle, + sweepAngle = sweep, + useCenter = false, + topLeft = Offset(diameterOffset, diameterOffset), + size = Size(arcDimen, arcDimen), + style = stroke, + ) +} + +private fun DrawScope.drawCircularIndicatorTrack(color: Color, stroke: Stroke) = drawCircularIndicator(0f, 360f, color, stroke) + +private fun DrawScope.drawIndeterminateCircularIndicator(startAngle: Float, strokeWidth: Dp, sweep: Float, color: Color, stroke: Stroke) { + val strokeCapOffset = if (stroke.cap == StrokeCap.Butt) { + 0f + } else { + // Length of arc is angle * radius + // Angle (radians) is length / radius + // The length should be the same as the stroke width for calculating the min angle + (180.0 / PI).toFloat() * (strokeWidth / (CircularIndicatorDiameter / 2)) / 2f + } + + // Adding a stroke cap draws half the stroke width behind the start point, so we want to + // move it forward by that amount so the arc visually appears in the correct place + val adjustedStartAngle = startAngle + strokeCapOffset + + // When the start and end angles are in the same place, we still want to draw a small sweep, so + // the stroke caps get added on both ends and we draw the correct minimum length arc + val adjustedSweep = max(sweep, 0.1f) + + drawCircularIndicator(adjustedStartAngle, adjustedSweep, color, stroke) +} + +private val CircularIndicatorDiameter = 38.dp +private const val RotationsPerCycle = 5 +private const val RotationDuration = 1332 +private const val BaseRotationAngle = 286f +private const val JumpRotationAngle = 290f +private const val HeadAndTailAnimationDuration = (RotationDuration * 0.5).toInt() +private const val HeadAndTailDelayDuration = HeadAndTailAnimationDuration +private val CircularEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) +private const val StartAngleOffset = -90f +private const val RotationAngleOffset = (BaseRotationAngle + JumpRotationAngle) % 360f diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt new file mode 100644 index 0000000000..11b5681f33 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R + +@Composable +internal fun NotAvailableFeature( + modifier: Modifier = Modifier, + message: String = stringResource(id = R.string.message_not_available_feature), +) { + Text(message, modifier = modifier) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/PodcastCard.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/PodcastCard.kt new file mode 100644 index 0000000000..a1a683f34f --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/PodcastCard.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.CardScale +import androidx.tv.material3.StandardCardContainer +import androidx.tv.material3.Text +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +internal fun PodcastCard(podcastInfo: PodcastInfo, onClick: () -> Unit, modifier: Modifier = Modifier) { + StandardCardContainer( + imageCard = { + Card( + onClick = onClick, + interactionSource = it, + scale = CardScale.None, + shape = CardDefaults.shape(RoundedCornerShape(12.dp)), + ) { + Thumbnail( + podcastInfo = podcastInfo, + size = JetcasterAppDefaults.thumbnailSize.podcast, + ) + } + }, + title = { + Text(text = podcastInfo.title, modifier = Modifier.padding(top = 12.dp)) + }, + modifier = modifier, + ) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Seekbar.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Seekbar.kt new file mode 100644 index 0000000000..4ffa5f50f5 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Seekbar.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import java.time.Duration + +@Composable +internal fun Seekbar( + timeElapsed: Duration, + length: Duration, + modifier: Modifier = Modifier, + onMoveLeft: () -> Unit = {}, + onMoveRight: () -> Unit = {}, + knobSize: Dp = 8.dp, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + color: Color = MaterialTheme.colorScheme.onSurface, +) { + val brush = SolidColor(color) + val isFocused by interactionSource.collectIsFocusedAsState() + val outlineSize = knobSize * 1.5f + Box( + modifier + .drawWithCache { + onDrawBehind { + val knobRadius = knobSize.toPx() / 2 + + val start = Offset.Zero.copy(y = knobRadius) + val end = start.copy(x = size.width) + + val knobCenter = start.copy( + x = timeElapsed.seconds.toFloat() / length.seconds.toFloat() * size.width, + ) + drawLine( + brush, start, end, + ) + if (isFocused) { + val outlineColor = color.copy(alpha = 0.6f) + drawCircle(outlineColor, outlineSize.toPx() / 2, knobCenter) + } + drawCircle(brush, knobRadius, knobCenter) + } + } + .height(outlineSize) + .focusable(true, interactionSource) + .onKeyEvent { + when { + it.type == KeyEventType.KeyUp && it.key == Key.DirectionLeft -> { + onMoveLeft() + true + } + + it.type == KeyEventType.KeyUp && it.key == Key.DirectionRight -> { + onMoveRight() + true + } + + else -> false + } + }, + ) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt new file mode 100644 index 0000000000..fa5f30104e --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun Thumbnail( + podcastInfo: PodcastInfo, + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(12.dp), + size: DpSize = DpSize( + JetcasterAppDefaults.cardWidth.medium, + JetcasterAppDefaults.cardWidth.medium, + ), + contentScale: ContentScale = ContentScale.Crop, +) = Thumbnail( + podcastInfo.imageUrl, + modifier, + shape, + size, + contentScale, +) + +@Composable +fun Thumbnail( + episode: PlayerEpisode, + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(12.dp), + size: DpSize = DpSize( + JetcasterAppDefaults.cardWidth.medium, + JetcasterAppDefaults.cardWidth.medium, + ), + contentScale: ContentScale = ContentScale.Crop, +) = Thumbnail( + episode.podcastImageUrl, + modifier, + shape, + size, + contentScale, +) + +@Composable +fun Thumbnail( + url: String, + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(12.dp), + size: DpSize = DpSize( + JetcasterAppDefaults.cardWidth.medium, + JetcasterAppDefaults.cardWidth.medium, + ), + contentScale: ContentScale = ContentScale.Crop, +) = PodcastImage( + podcastImageUrl = url, + contentDescription = null, + contentScale = contentScale, + modifier = modifier + .clip(shape) + .size(size), +) diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/TwoColumn.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/TwoColumn.kt new file mode 100644 index 0000000000..7f5e4b9190 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/TwoColumn.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +internal fun TwoColumn( + first: (@Composable RowScope.() -> Unit), + second: (@Composable RowScope.() -> Unit), + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn), +) { + Row( + horizontalArrangement = horizontalArrangement, + modifier = modifier, + ) { + first() + second() + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt new file mode 100644 index 0000000000..bf3461948b --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.discover + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.Tab +import androidx.tv.material3.TabRow +import androidx.tv.material3.Text +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.tv.model.CategoryInfoList +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import com.example.jetcaster.tv.ui.component.Catalog +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun DiscoverScreen( + showPodcastDetails: (PodcastInfo) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + discoverScreenViewModel: DiscoverScreenViewModel = hiltViewModel(), +) { + val uiState by discoverScreenViewModel.uiState.collectAsStateWithLifecycle() + + when (val s = uiState) { + DiscoverScreenUiState.Loading -> { + Loading( + modifier = Modifier + .fillMaxSize() + .then(modifier), + ) + } + + is DiscoverScreenUiState.Ready -> { + CatalogWithCategorySelection( + categoryInfoList = s.categoryInfoList, + podcastList = s.podcastList, + selectedCategory = s.selectedCategory, + latestEpisodeList = s.latestEpisodeList, + onPodcastSelected = showPodcastDetails, + onCategorySelected = discoverScreenViewModel::selectCategory, + onEpisodeSelected = { + discoverScreenViewModel.play(it) + playEpisode(it) + }, + modifier = Modifier + .fillMaxSize() + .then(modifier), + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun CatalogWithCategorySelection( + categoryInfoList: CategoryInfoList, + podcastList: PodcastList, + selectedCategory: CategoryInfo, + latestEpisodeList: EpisodeList, + onPodcastSelected: (PodcastInfo) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), +) { + val (focusRequester, selectedTab) = remember { + FocusRequester.createRefs() + } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + val selectedTabIndex = categoryInfoList.indexOf(selectedCategory) + + Catalog( + podcastList = podcastList, + latestEpisodeList = latestEpisodeList, + onPodcastSelected = { + focusRequester.saveFocusedChild() + onPodcastSelected(it) + }, + onEpisodeSelected = { + focusRequester.saveFocusedChild() + onEpisodeSelected(it) + }, + modifier = modifier.focusRequester(focusRequester), + state = state, + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = Modifier.focusProperties { + onEnter = { + selectedTab + } + }, + ) { + categoryInfoList.forEachIndexed { index, category -> + val tabModifier = if (selectedTabIndex == index) { + Modifier.focusRequester(selectedTab) + } else { + Modifier + } + + Tab( + selected = index == selectedTabIndex, + onFocus = { + onCategorySelected(category) + }, + modifier = tabModifier, + ) { + Text( + text = category.name, + modifier = Modifier.padding(JetcasterAppDefaults.padding.tab), + ) + } + } + } + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt new file mode 100644 index 0000000000..46b3188560 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.discover + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.core.player.model.toPlayerEpisode +import com.example.jetcaster.tv.model.CategoryInfoList +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class DiscoverScreenViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val categoryStore: CategoryStore, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + private val _selectedCategory = MutableStateFlow(null) + + private val categoryListFlow = categoryStore + .categoriesSortedByPodcastCount() + .map { categoryList -> + categoryList.map { category -> + CategoryInfo( + id = category.id, + name = category.name.filter { !it.isWhitespace() }, + ) + } + } + + private val selectedCategoryFlow = combine( + categoryListFlow, + _selectedCategory, + ) { categoryList, category -> + category ?: categoryList.firstOrNull() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val podcastInSelectedCategory = selectedCategoryFlow.flatMapLatest { + if (it != null) { + categoryStore.podcastsInCategorySortedByPodcastCount(it.id, limit = 10) + } else { + flowOf(emptyList()) + } + }.map { list -> + list.map { it.asExternalModel() } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val latestEpisodeFlow = selectedCategoryFlow.flatMapLatest { + if (it != null) { + categoryStore.episodesFromPodcastsInCategory(it.id, 20) + } else { + flowOf(emptyList()) + } + }.map { list -> + EpisodeList(list.map { it.toPlayerEpisode() }) + } + + val uiState = combine( + categoryListFlow, + selectedCategoryFlow, + podcastInSelectedCategory, + latestEpisodeFlow, + ) { categoryList, category, podcastList, latestEpisodes -> + if (category != null) { + DiscoverScreenUiState.Ready( + CategoryInfoList(categoryList), + category, + podcastList, + latestEpisodes, + ) + } else { + DiscoverScreenUiState.Loading + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + DiscoverScreenUiState.Loading, + ) + + init { + refresh() + } + + fun selectCategory(category: CategoryInfo) { + _selectedCategory.value = category + } + + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + + private fun refresh() { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } +} + +sealed interface DiscoverScreenUiState { + data object Loading : DiscoverScreenUiState + data class Ready( + val categoryInfoList: CategoryInfoList, + val selectedCategory: CategoryInfo, + val podcastList: PodcastList, + val latestEpisodeList: EpisodeList, + ) : DiscoverScreenUiState +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt new file mode 100644 index 0000000000..692e913b17 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.episode + +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.tv.ui.component.BackgroundContainer +import com.example.jetcaster.tv.ui.component.EnqueueButton +import com.example.jetcaster.tv.ui.component.EpisodeDataAndDuration +import com.example.jetcaster.tv.ui.component.ErrorState +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.component.PlayButton +import com.example.jetcaster.tv.ui.component.Thumbnail +import com.example.jetcaster.tv.ui.component.TwoColumn +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun EpisodeScreen( + playEpisode: () -> Unit, + backToHome: () -> Unit, + modifier: Modifier = Modifier, + episodeScreenViewModel: EpisodeScreenViewModel = hiltViewModel(), +) { + + val uiState by episodeScreenViewModel.uiStateFlow.collectAsStateWithLifecycle() + + val screenModifier = modifier.fillMaxSize() + when (val s = uiState) { + EpisodeScreenUiState.Loading -> Loading(modifier = screenModifier) + + EpisodeScreenUiState.Error -> ErrorState(backToHome = backToHome, modifier = screenModifier) + + is EpisodeScreenUiState.Ready -> EpisodeDetailsWithBackground( + playerEpisode = s.playerEpisode, + playEpisode = { + episodeScreenViewModel.play(it) + playEpisode() + }, + addPlayList = episodeScreenViewModel::addPlayList, + modifier = screenModifier, + ) + } +} + +@Composable +private fun EpisodeDetailsWithBackground( + playerEpisode: PlayerEpisode, + playEpisode: (PlayerEpisode) -> Unit, + addPlayList: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, +) { + BackgroundContainer( + playerEpisode = playerEpisode, + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + EpisodeDetails( + playerEpisode = playerEpisode, + playEpisode = playEpisode, + addPlayList = addPlayList, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.episode.intoPaddingValues()), + ) + } +} + +@Composable +private fun EpisodeDetails( + playerEpisode: PlayerEpisode, + playEpisode: (PlayerEpisode) -> Unit, + addPlayList: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, +) { + TwoColumn( + first = { + Thumbnail( + episode = playerEpisode, + size = JetcasterAppDefaults.thumbnailSize.episodeDetails, + ) + }, + second = { + EpisodeInfo( + playerEpisode = playerEpisode, + playEpisode = { playEpisode(playerEpisode) }, + addPlayList = { addPlayList(playerEpisode) }, + modifier = Modifier.weight(1f), + ) + }, + modifier = modifier, + ) +} + +@Composable +private fun EpisodeInfo(playerEpisode: PlayerEpisode, playEpisode: () -> Unit, addPlayList: () -> Unit, modifier: Modifier = Modifier) { + val duration = playerEpisode.duration + + Column(modifier) { + Text(text = playerEpisode.author, style = MaterialTheme.typography.bodySmall) + Text(text = playerEpisode.title, style = MaterialTheme.typography.headlineLarge) + if (duration != null) { + EpisodeDataAndDuration(offsetDateTime = playerEpisode.published, duration = duration) + } + Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph)) + Text( + text = playerEpisode.summary, + softWrap = true, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph)) + Controls(playEpisode = playEpisode, addPlayList = addPlayList) + } +} + +@Composable +private fun Controls(playEpisode: () -> Unit, addPlayList: () -> Unit, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + PlayButton(onClick = playEpisode) + EnqueueButton(onClick = addPlayList) + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt new file mode 100644 index 0000000000..dd773b50d5 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.episode + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.core.player.model.toPlayerEpisode +import com.example.jetcaster.tv.ui.Screen +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class EpisodeScreenViewModel @Inject constructor( + handle: SavedStateHandle, + podcastsRepository: PodcastsRepository, + episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + private val episodeUriFlow = handle.getStateFlow(Screen.Episode.PARAMETER_NAME, null) + + @OptIn(ExperimentalCoroutinesApi::class) + private val episodeToPodcastFlow = episodeUriFlow.flatMapLatest { + if (it != null) { + episodeStore.episodeAndPodcastWithUri(it) + } else { + flowOf(null) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + null, + ) + + val uiStateFlow = episodeToPodcastFlow.map { + if (it != null) { + EpisodeScreenUiState.Ready(it.toPlayerEpisode()) + } else { + EpisodeScreenUiState.Error + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + EpisodeScreenUiState.Loading, + ) + + fun addPlayList(episode: PlayerEpisode) { + episodePlayer.addToQueue(episode) + } + + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } +} + +sealed interface EpisodeScreenUiState { + data object Loading : EpisodeScreenUiState + data object Error : EpisodeScreenUiState + data class Ready(val playerEpisode: PlayerEpisode) : EpisodeScreenUiState +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt new file mode 100644 index 0000000000..51fc9b537d --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.library + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import com.example.jetcaster.tv.ui.component.Catalog +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun LibraryScreen( + modifier: Modifier = Modifier, + navigateToDiscover: () -> Unit, + showPodcastDetails: (PodcastInfo) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + libraryScreenViewModel: LibraryScreenViewModel = hiltViewModel(), +) { + val uiState by libraryScreenViewModel.uiState.collectAsStateWithLifecycle() + when (val s = uiState) { + LibraryScreenUiState.Loading -> Loading(modifier = modifier) + + LibraryScreenUiState.NoSubscribedPodcast -> { + NavigateToDiscover(onNavigationRequested = navigateToDiscover, modifier = modifier) + } + + is LibraryScreenUiState.Ready -> Library( + podcastList = s.subscribedPodcastList, + episodeList = s.latestEpisodeList, + showPodcastDetails = showPodcastDetails, + onEpisodeSelected = { + libraryScreenViewModel.playEpisode(it) + playEpisode(it) + }, + modifier = modifier, + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun Library( + podcastList: PodcastList, + episodeList: EpisodeList, + showPodcastDetails: (PodcastInfo) -> Unit, + onEpisodeSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Catalog( + podcastList = podcastList, + latestEpisodeList = episodeList, + onPodcastSelected = showPodcastDetails, + onEpisodeSelected = onEpisodeSelected, + modifier = modifier + .focusRequester(focusRequester) + .focusRestorer(), + ) +} + +@Composable +private fun NavigateToDiscover(onNavigationRequested: () -> Unit, modifier: Modifier = Modifier) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column { + Text( + text = stringResource(id = R.string.display_no_subscribed_podcast), + style = MaterialTheme.typography.displayMedium, + ) + Text(text = stringResource(id = R.string.message_no_subscribed_podcast)) + Button( + onClick = onNavigationRequested, + modifier = Modifier + .padding(top = JetcasterAppDefaults.gap.podcastRow) + .focusRequester(focusRequester), + ) { + Text(text = stringResource(id = R.string.label_navigate_to_discover)) + } + } + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt new file mode 100644 index 0000000000..03418dc485 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.core.player.model.toPlayerEpisode +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class LibraryScreenViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val episodeStore: EpisodeStore, + podcastStore: PodcastStore, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + private val followingPodcastListFlow = + podcastStore.followedPodcastsSortedByLastEpisode().map { list -> + list.map { it.asExternalModel() } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val latestEpisodeListFlow = podcastStore + .followedPodcastsSortedByLastEpisode() + .flatMapLatest { podcastList -> + if (podcastList.isNotEmpty()) { + combine(podcastList.map { episodeStore.episodesInPodcast(it.podcast.uri, 1) }) { + it.map { episodes -> + episodes.first() + } + } + } else { + flowOf(emptyList()) + } + }.map { list -> + EpisodeList(list.map { it.toPlayerEpisode() }) + } + + val uiState = + combine(followingPodcastListFlow, latestEpisodeListFlow) { podcastList, episodeList -> + if (podcastList.isEmpty()) { + LibraryScreenUiState.NoSubscribedPodcast + } else { + LibraryScreenUiState.Ready(podcastList, episodeList) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + LibraryScreenUiState.Loading, + ) + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } + + fun playEpisode(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } +} + +sealed interface LibraryScreenUiState { + data object Loading : LibraryScreenUiState + data object NoSubscribedPodcast : LibraryScreenUiState + data class Ready(val subscribedPodcastList: PodcastList, val latestEpisodeList: EpisodeList) : LibraryScreenUiState +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt new file mode 100644 index 0000000000..9dc24e7d40 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -0,0 +1,553 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.player + +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.session.MediaSession +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.modifiers.resizeWithContentScale +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.player.EpisodePlayerState +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.component.BackgroundContainer +import com.example.jetcaster.tv.ui.component.EnqueueButton +import com.example.jetcaster.tv.ui.component.EpisodeDetails +import com.example.jetcaster.tv.ui.component.EpisodeRow +import com.example.jetcaster.tv.ui.component.InfoButton +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.component.NextButton +import com.example.jetcaster.tv.ui.component.PlayPauseButton +import com.example.jetcaster.tv.ui.component.PreviousButton +import com.example.jetcaster.tv.ui.component.RewindButton +import com.example.jetcaster.tv.ui.component.Seekbar +import com.example.jetcaster.tv.ui.component.SkipButton +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults +import java.time.Duration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun PlayerScreen( + backToHome: () -> Unit, + showDetails: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + playScreenViewModel: PlayerScreenViewModel = hiltViewModel(), +) { + val uiState by playScreenViewModel.uiStateFlow.collectAsStateWithLifecycle() + + when (val s = uiState) { + PlayerScreenUiState.Loading -> Loading(modifier) + + PlayerScreenUiState.NoEpisodeInQueue -> { + NoEpisodeInQueue(backToHome = backToHome, modifier = modifier) + } + + is PlayerScreenUiState.Ready -> { + Player( + episodePlayerState = s.playerState, + play = playScreenViewModel::play, + pause = playScreenViewModel::pause, + previous = playScreenViewModel::previous, + next = playScreenViewModel::next, + skip = playScreenViewModel::skip, + rewind = playScreenViewModel::rewind, + enqueue = playScreenViewModel::enqueue, + playEpisode = playScreenViewModel::play, + showDetails = showDetails, + ) + } + } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +private fun Player( + episodePlayerState: EpisodePlayerState, + play: () -> Unit, + pause: () -> Unit, + previous: () -> Unit, + next: () -> Unit, + skip: () -> Unit, + rewind: () -> Unit, + enqueue: (PlayerEpisode) -> Unit, + showDetails: (PlayerEpisode) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + autoStart: Boolean = true, +) { + LaunchedEffect(key1 = autoStart) { + if (autoStart && !episodePlayerState.isPlaying) { + play() + } + } + + val currentEpisode = episodePlayerState.currentEpisode + + if (currentEpisode != null) { + val context = LocalContext.current + + val exoPlayer = rememberPlayer(context) + + DisposableEffect(exoPlayer, playEpisode) { + exoPlayer.setMediaItem(MediaItem.fromUri(Uri.parse(currentEpisode.mediaUrls[0]))) + val mediaSession = MediaSession.Builder(context, exoPlayer).build() + exoPlayer.prepare() + exoPlayer.play() + onDispose { + mediaSession.release() + exoPlayer.release() + } + } + // Adding PlayerSurface at the bottom of the stack + // as it is just the audio player + Box { + PlayerSurface( + player = exoPlayer, + modifier = Modifier.resizeWithContentScale( + contentScale = ContentScale.Fit, + sourceSizeDp = null, + ), + ) + EpisodePlayerWithBackground( + playerEpisode = currentEpisode, + queue = EpisodeList(episodePlayerState.queue), + isPlaying = episodePlayerState.isPlaying, + timeElapsed = episodePlayerState.timeElapsed, + play = ( + { + play() + exoPlayer.play() + } + ), + pause = ( + { + pause() + exoPlayer.pause() + } + ), + previous = previous, + next = next, + skip = ( + { + skip() + exoPlayer.seekForward() + } + ), + rewind = ( + { + rewind() + exoPlayer.seekBack() + } + ), + enqueue = enqueue, + showDetails = showDetails, + playEpisode = playEpisode, + modifier = modifier, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun EpisodePlayerWithBackground( + playerEpisode: PlayerEpisode, + queue: EpisodeList, + isPlaying: Boolean, + timeElapsed: Duration, + play: () -> Unit, + pause: () -> Unit, + previous: () -> Unit, + next: () -> Unit, + skip: () -> Unit, + rewind: () -> Unit, + enqueue: (PlayerEpisode) -> Unit, + showDetails: (PlayerEpisode) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, +) { + val episodePlayer = remember { FocusRequester() } + + LaunchedEffect(Unit) { + episodePlayer.requestFocus() + } + + BackgroundContainer( + playerEpisode = playerEpisode, + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + + EpisodePlayer( + playerEpisode = playerEpisode, + isPlaying = isPlaying, + timeElapsed = timeElapsed, + play = play, + pause = pause, + previous = previous, + next = next, + skip = skip, + rewind = rewind, + enqueue = enqueue, + showDetails = showDetails, + focusRequester = episodePlayer, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.player.intoPaddingValues()), + ) + + PlayerQueueOverlay( + playerEpisodeList = queue, + onSelected = playEpisode, + modifier = Modifier.fillMaxSize(), + contentPadding = JetcasterAppDefaults.overScanMargin.player.copy(top = 0.dp) + .intoPaddingValues(), + offset = DpOffset(0.dp, 136.dp), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun EpisodePlayer( + playerEpisode: PlayerEpisode, + isPlaying: Boolean, + timeElapsed: Duration, + play: () -> Unit, + pause: () -> Unit, + previous: () -> Unit, + next: () -> Unit, + skip: () -> Unit, + rewind: () -> Unit, + enqueue: (PlayerEpisode) -> Unit, + showDetails: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + bringIntoViewRequester: BringIntoViewRequester = remember { BringIntoViewRequester() }, + coroutineScope: CoroutineScope = rememberCoroutineScope(), + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + Column( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.section), + modifier = Modifier + .bringIntoViewRequester(bringIntoViewRequester) + .onFocusChanged { + if (it.hasFocus) { + coroutineScope.launch { + bringIntoViewRequester.bringIntoView() + } + } + } + .then(modifier), + ) { + EpisodeDetails( + playerEpisode = playerEpisode, + content = {}, + controls = { + EpisodeControl( + showDetails = { showDetails(playerEpisode) }, + enqueue = { enqueue(playerEpisode) }, + ) + }, + ) + PlayerControl( + isPlaying = isPlaying, + timeElapsed = timeElapsed, + length = playerEpisode.duration, + play = play, + pause = pause, + previous = previous, + next = next, + skip = skip, + rewind = rewind, + focusRequester = focusRequester, + ) + } +} + +@Composable +private fun EpisodeControl(showDetails: () -> Unit, enqueue: () -> Unit, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + ) { + EnqueueButton( + onClick = enqueue, + modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.default.intoDpSize()), + ) + InfoButton( + onClick = showDetails, + modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.default.intoDpSize()), + ) + } +} + +@Composable +private fun PlayerControl( + isPlaying: Boolean, + timeElapsed: Duration, + length: Duration?, + play: () -> Unit, + pause: () -> Unit, + previous: () -> Unit, + next: () -> Unit, + skip: () -> Unit, + rewind: () -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + val playPauseButton = remember { FocusRequester() } + + Column( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy( + JetcasterAppDefaults.gap.default, + Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + playPauseButton.requestFocus() + } + } + .focusable(), + ) { + PreviousButton( + onClick = previous, + modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.medium.intoDpSize()), + ) + RewindButton( + onClick = rewind, + modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.medium.intoDpSize()), + ) + PlayPauseButton( + isPlaying = isPlaying, + onClick = { + if (isPlaying) { + pause() + } else { + play() + } + }, + modifier = Modifier + .size(JetcasterAppDefaults.iconButtonSize.large.intoDpSize()) + .focusRequester(playPauseButton), + ) + SkipButton( + onClick = skip, + modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.medium.intoDpSize()), + ) + NextButton( + onClick = next, + modifier = Modifier.size(JetcasterAppDefaults.iconButtonSize.medium.intoDpSize()), + ) + } + if (length != null) { + ElapsedTimeIndicator(timeElapsed, length, skip, rewind) + } + } +} + +@Composable +private fun ElapsedTimeIndicator( + timeElapsed: Duration, + length: Duration, + skip: () -> Unit, + rewind: () -> Unit, + modifier: Modifier = Modifier, + knobSize: Dp = 8.dp, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.tiny), + ) { + ElapsedTime(timeElapsed = timeElapsed, length = length) + Seekbar( + timeElapsed = timeElapsed, + length = length, + knobSize = knobSize, + onMoveLeft = rewind, + onMoveRight = skip, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ElapsedTime( + timeElapsed: Duration, + length: Duration, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodySmall, +) { + val elapsed = + stringResource( + R.string.minutes_seconds, + timeElapsed.toMinutes(), + timeElapsed.toSeconds() % 60, + ) + val l = + stringResource(R.string.minutes_seconds, length.toMinutes(), length.toSeconds() % 60) + Text( + text = stringResource(R.string.elapsed_time, elapsed, l), + style = style, + modifier = modifier, + ) +} + +@Composable +private fun NoEpisodeInQueue( + backToHome: () -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Box(contentAlignment = Alignment.Center, modifier = modifier) { + Column { + Text( + text = stringResource(R.string.display_nothing_in_queue), + style = MaterialTheme.typography.displayMedium, + ) + Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph)) + Text(text = stringResource(R.string.message_nothing_in_queue)) + Button(onClick = backToHome, modifier = Modifier.focusRequester(focusRequester)) { + Text(text = stringResource(R.string.label_back_to_home)) + } + } + } +} + +@Composable +private fun PlayerQueueOverlay( + playerEpisodeList: EpisodeList, + onSelected: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + contentPadding: PaddingValues = PaddingValues(), + contentAlignment: Alignment = Alignment.BottomStart, + scrim: DrawScope.() -> Unit = { + val brush = Brush.verticalGradient( + listOf(Color.Transparent, Color.Black), + ) + drawRect(brush, blendMode = BlendMode.Multiply) + }, + offset: DpOffset = DpOffset.Zero, +) { + var hasFocus by remember { mutableStateOf(false) } + val actualOffset = if (hasFocus) { + DpOffset.Zero + } else { + offset + } + Box( + modifier = modifier.drawWithCache { + onDrawBehind { + if (hasFocus) { + scrim() + } + } + }, + contentAlignment = contentAlignment, + ) { + EpisodeRow( + playerEpisodeList = playerEpisodeList, + onSelected = onSelected, + horizontalArrangement = horizontalArrangement, + contentPadding = contentPadding, + modifier = Modifier + .offset(actualOffset.x, actualOffset.y) + .onFocusChanged { hasFocus = it.hasFocus }, + ) + } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +internal fun rememberPlayer(context: Context) = remember { + ExoPlayer.Builder(context) + .setSeekForwardIncrementMs(10 * 1000) + .setSeekBackIncrementMs(10 * 1000) + .setMediaSourceFactory(ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))) + .setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING) + .build() + .apply { + playWhenReady = true + repeatMode = REPEAT_MODE_ALL + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt new file mode 100644 index 0000000000..a03381604e --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.player + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.EpisodePlayerState +import com.example.jetcaster.core.player.model.PlayerEpisode +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Duration +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class PlayerScreenViewModel @Inject constructor(private val episodePlayer: EpisodePlayer) : ViewModel() { + + val uiStateFlow = episodePlayer.playerState.map { + if (it.currentEpisode == null && it.queue.isEmpty()) { + PlayerScreenUiState.NoEpisodeInQueue + } else { + PlayerScreenUiState.Ready(it) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + PlayerScreenUiState.Loading, + ) + + private val skipAmount = Duration.ofSeconds(10L) + + fun play() { + if (episodePlayer.playerState.value.currentEpisode == null) { + episodePlayer.next() + } + episodePlayer.play() + } + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + + fun pause() = episodePlayer.pause() + fun next() = episodePlayer.next() + fun previous() = episodePlayer.previous() + fun skip() { + episodePlayer.advanceBy(skipAmount) + } + + fun rewind() { + episodePlayer.rewindBy(skipAmount) + } + + fun enqueue(playerEpisode: PlayerEpisode) { + episodePlayer.addToQueue(playerEpisode) + } +} + +sealed interface PlayerScreenUiState { + data object Loading : PlayerScreenUiState + data class Ready(val playerState: EpisodePlayerState) : PlayerScreenUiState + + data object NoEpisodeInQueue : PlayerScreenUiState +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreen.kt new file mode 100644 index 0000000000..bf45a54cc7 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreen.kt @@ -0,0 +1,374 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.podcast + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.component.BackgroundContainer +import com.example.jetcaster.tv.ui.component.ButtonWithIcon +import com.example.jetcaster.tv.ui.component.EnqueueButton +import com.example.jetcaster.tv.ui.component.EpisodeDataAndDuration +import com.example.jetcaster.tv.ui.component.ErrorState +import com.example.jetcaster.tv.ui.component.InfoButton +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.component.PlayButton +import com.example.jetcaster.tv.ui.component.Thumbnail +import com.example.jetcaster.tv.ui.component.TwoColumn +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun PodcastDetailsScreen( + backToHomeScreen: () -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + showEpisodeDetails: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + podcastDetailsScreenViewModel: PodcastDetailsScreenViewModel = hiltViewModel(), +) { + val uiState by podcastDetailsScreenViewModel.uiStateFlow.collectAsStateWithLifecycle() + when (val s = uiState) { + PodcastScreenUiState.Loading -> Loading(modifier = modifier) + + PodcastScreenUiState.Error -> ErrorState(backToHome = backToHomeScreen, modifier = modifier) + + is PodcastScreenUiState.Ready -> PodcastDetailsWithBackground( + podcastInfo = s.podcastInfo, + episodeList = s.episodeList, + isSubscribed = s.isSubscribed, + subscribe = podcastDetailsScreenViewModel::subscribe, + unsubscribe = podcastDetailsScreenViewModel::unsubscribe, + playEpisode = { + podcastDetailsScreenViewModel.play(it) + playEpisode(it) + }, + enqueue = podcastDetailsScreenViewModel::enqueue, + showEpisodeDetails = showEpisodeDetails, + ) + } +} + +@Composable +private fun PodcastDetailsWithBackground( + podcastInfo: PodcastInfo, + episodeList: EpisodeList, + isSubscribed: Boolean, + subscribe: (PodcastInfo, Boolean) -> Unit, + unsubscribe: (PodcastInfo, Boolean) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + showEpisodeDetails: (PlayerEpisode) -> Unit, + enqueue: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + + BackgroundContainer(podcastInfo = podcastInfo, modifier = modifier) { + PodcastDetails( + podcastInfo = podcastInfo, + episodeList = episodeList, + isSubscribed = isSubscribed, + subscribe = subscribe, + unsubscribe = unsubscribe, + playEpisode = playEpisode, + focusRequester = focusRequester, + showEpisodeDetails = showEpisodeDetails, + enqueue = enqueue, + modifier = Modifier + .fillMaxSize(), + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PodcastDetails( + podcastInfo: PodcastInfo, + episodeList: EpisodeList, + isSubscribed: Boolean, + subscribe: (PodcastInfo, Boolean) -> Unit, + unsubscribe: (PodcastInfo, Boolean) -> Unit, + playEpisode: (PlayerEpisode) -> Unit, + showEpisodeDetails: (PlayerEpisode) -> Unit, + enqueue: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + TwoColumn( + modifier = modifier, + horizontalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn), + first = { + PodcastInfo( + podcastInfo = podcastInfo, + isSubscribed = isSubscribed, + subscribe = subscribe, + unsubscribe = unsubscribe, + modifier = Modifier + .weight(0.3f) + .padding( + JetcasterAppDefaults.overScanMargin.podcast.copy(end = 0.dp) + .intoPaddingValues(), + ), + ) + }, + second = { + PodcastEpisodeList( + episodeList = episodeList, + playEpisode = { playEpisode(it) }, + showDetails = showEpisodeDetails, + enqueue = enqueue, + modifier = Modifier + .focusRequester(focusRequester) + .focusRestorer() + .weight(0.7f), + ) + }, + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Composable +private fun PodcastInfo( + podcastInfo: PodcastInfo, + isSubscribed: Boolean, + subscribe: (PodcastInfo, Boolean) -> Unit, + unsubscribe: (PodcastInfo, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Thumbnail(podcastInfo = podcastInfo) + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = podcastInfo.author, + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = podcastInfo.title, + style = MaterialTheme.typography.headlineSmall, + ) + Text( + text = podcastInfo.description, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + ToggleSubscriptionButton( + podcastInfo, + isSubscribed, + subscribe, + unsubscribe, + modifier = Modifier + .padding(top = JetcasterAppDefaults.gap.podcastRow), + ) + } +} + +@Composable +private fun ToggleSubscriptionButton( + podcastInfo: PodcastInfo, + isSubscribed: Boolean, + subscribe: (PodcastInfo, Boolean) -> Unit, + unsubscribe: (PodcastInfo, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val iconId = if (isSubscribed) { + R.drawable.ic_remove + } else { + R.drawable.ic_add + } + val label = if (isSubscribed) { + stringResource(R.string.label_unsubscribe) + } else { + stringResource(R.string.label_subscribe) + } + val action = if (isSubscribed) { + unsubscribe + } else { + subscribe + } + ButtonWithIcon( + label = label, + iconId = iconId, + onClick = { action(podcastInfo, isSubscribed) }, + scale = ButtonDefaults.scale(scale = 1f), + modifier = modifier, + ) +} + +@Composable +private fun PodcastEpisodeList( + episodeList: EpisodeList, + playEpisode: (PlayerEpisode) -> Unit, + showDetails: (PlayerEpisode) -> Unit, + enqueue: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), + modifier = modifier, + contentPadding = JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues(), + ) { + items(episodeList) { + EpisodeListItem( + playerEpisode = it, + onEpisodeSelected = { playEpisode(it) }, + onInfoClicked = { showDetails(it) }, + onEnqueueClicked = { enqueue(it) }, + ) + } + } +} + +@Composable +private fun EpisodeListItem( + playerEpisode: PlayerEpisode, + onEpisodeSelected: () -> Unit, + onInfoClicked: () -> Unit, + onEnqueueClicked: () -> Unit, + modifier: Modifier = Modifier, + borderWidth: Dp = 2.dp, + cornerRadius: Dp = 12.dp, +) { + var hasFocus by remember { + mutableStateOf(false) + } + val shape = RoundedCornerShape(cornerRadius) + + val backgroundColor = if (hasFocus) { + MaterialTheme.colorScheme.surface + } else { + Color.Transparent + } + + val borderColor = if (hasFocus) { + MaterialTheme.colorScheme.border + } else { + Color.Transparent + } + val elevation = if (hasFocus) { + 10.dp + } else { + 0.dp + } + + EpisodeListItemContentLayer( + playerEpisode = playerEpisode, + onEpisodeSelected = onEpisodeSelected, + onInfoClicked = onInfoClicked, + onEnqueueClicked = onEnqueueClicked, + modifier = modifier + .clip(shape) + .onFocusChanged { + hasFocus = it.hasFocus + } + .border(borderWidth, borderColor, shape) + .background(backgroundColor) + .shadow(elevation, shape) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp, end = 16.dp), + ) +} + +@Composable +private fun EpisodeListItemContentLayer( + playerEpisode: PlayerEpisode, + onEpisodeSelected: () -> Unit, + onInfoClicked: () -> Unit, + onEnqueueClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + val duration = playerEpisode.duration + val playButton = remember { FocusRequester() } + Box( + contentAlignment = Alignment.CenterStart, + modifier = modifier, + ) { + + Column( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.tiny), + ) { + EpisodeTitle(playerEpisode) + Row( + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.default), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(top = JetcasterAppDefaults.gap.paragraph), + ) { + PlayButton( + onClick = onEpisodeSelected, + modifier = Modifier.focusRequester(playButton), + ) + if (duration != null) { + EpisodeDataAndDuration(playerEpisode.published, duration) + } + Spacer(modifier = Modifier.weight(1f)) + EnqueueButton(onClick = onEnqueueClicked) + InfoButton(onClick = onInfoClicked) + } + } + } +} + +@Composable +private fun EpisodeTitle(playerEpisode: PlayerEpisode, modifier: Modifier = Modifier) { + Text( + text = playerEpisode.title, + style = MaterialTheme.typography.titleLarge, + modifier = modifier, + ) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreenViewModel.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreenViewModel.kt new file mode 100644 index 0000000000..09731eca09 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreenViewModel.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.podcast + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.core.player.model.toPlayerEpisode +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.Screen +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class PodcastDetailsScreenViewModel @Inject constructor( + handle: SavedStateHandle, + private val podcastStore: PodcastStore, + episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + private val podcastUri = handle.get(Screen.Podcast.PARAMETER_NAME) + + @OptIn(ExperimentalCoroutinesApi::class) + private val podcastFlow = + handle.getStateFlow(Screen.Podcast.PARAMETER_NAME, null).flatMapLatest { + if (it != null) { + podcastStore.podcastWithUri(it) + } else { + flowOf(null) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val episodeListFlow = podcastFlow.flatMapLatest { + if (it != null) { + episodeStore.episodesInPodcast(it.uri) + } else { + flowOf(emptyList()) + } + }.map { list -> + EpisodeList(list.map { it.toPlayerEpisode() }) + } + + private val subscribedPodcastListFlow = + podcastStore.followedPodcastsSortedByLastEpisode() + + val uiStateFlow = combine( + podcastFlow, + episodeListFlow, + subscribedPodcastListFlow, + ) { podcast, episodeList, subscribedPodcastList -> + if (podcast != null) { + val isSubscribed = subscribedPodcastList.any { it.podcast.uri == podcastUri } + PodcastScreenUiState.Ready(podcast.asExternalModel(), episodeList, isSubscribed) + } else { + PodcastScreenUiState.Error + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + PodcastScreenUiState.Loading, + ) + + fun subscribe(podcastInfo: PodcastInfo, isSubscribed: Boolean) { + if (!isSubscribed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcastInfo.uri) + } + } + } + + fun unsubscribe(podcastInfo: PodcastInfo, isSubscribed: Boolean) { + if (isSubscribed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcastInfo.uri) + } + } + } + + fun play(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + + fun enqueue(playerEpisode: PlayerEpisode) { + episodePlayer.addToQueue(playerEpisode) + } +} + +sealed interface PodcastScreenUiState { + data object Loading : PodcastScreenUiState + data object Error : PodcastScreenUiState + data class Ready(val podcastInfo: PodcastInfo, val episodeList: EpisodeList, val isSubscribed: Boolean) : PodcastScreenUiState +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000000..b9cdd39734 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.profile + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.component.NotAvailableFeature + +@Composable +fun ProfileScreen(modifier: Modifier = Modifier) { + NotAvailableFeature(modifier = modifier) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt new file mode 100644 index 0000000000..1bf6edfdfb --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt @@ -0,0 +1,272 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.FilterChip +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.model.CategorySelectionList +import com.example.jetcaster.tv.model.PodcastList +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.component.PodcastCard +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun SearchScreen( + onPodcastSelected: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, + searchScreenViewModel: SearchScreenViewModel = hiltViewModel(), +) { + val uiState by searchScreenViewModel.uiStateFlow.collectAsStateWithLifecycle() + + when (val s = uiState) { + SearchScreenUiState.Loading -> Loading(modifier = modifier) + + is SearchScreenUiState.Ready -> Ready( + keyword = s.keyword, + categorySelectionList = s.categorySelectionList, + onKeywordInput = searchScreenViewModel::setKeyword, + onCategorySelected = searchScreenViewModel::addCategoryToSelectedCategoryList, + onCategoryUnselected = searchScreenViewModel::removeCategoryFromSelectedCategoryList, + modifier = modifier, + ) + + is SearchScreenUiState.HasResult -> HasResult( + keyword = s.keyword, + categorySelectionList = s.categorySelectionList, + podcastList = s.result, + onKeywordInput = searchScreenViewModel::setKeyword, + onCategorySelected = searchScreenViewModel::addCategoryToSelectedCategoryList, + onCategoryUnselected = searchScreenViewModel::removeCategoryFromSelectedCategoryList, + onPodcastSelected = onPodcastSelected, + modifier = modifier, + ) + } +} + +@Composable +private fun Ready( + keyword: String, + categorySelectionList: CategorySelectionList, + onKeywordInput: (String) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + onCategoryUnselected: (CategoryInfo) -> Unit, + modifier: Modifier = Modifier, +) { + Controls( + keyword = keyword, + categorySelectionList = categorySelectionList, + onKeywordInput = onKeywordInput, + onCategorySelected = onCategorySelected, + onCategoryUnselected = onCategoryUnselected, + modifier = modifier, + toRequestFocus = true, + ) +} + +@Composable +private fun HasResult( + keyword: String, + categorySelectionList: CategorySelectionList, + podcastList: PodcastList, + onKeywordInput: (String) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + onCategoryUnselected: (CategoryInfo) -> Unit, + onPodcastSelected: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, +) { + SearchResult( + podcastList = podcastList, + onPodcastSelected = onPodcastSelected, + header = { + Controls( + keyword = keyword, + categorySelectionList = categorySelectionList, + onKeywordInput = onKeywordInput, + onCategorySelected = onCategorySelected, + onCategoryUnselected = onCategoryUnselected, + ) + }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun Controls( + keyword: String, + categorySelectionList: CategorySelectionList, + onKeywordInput: (String) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + onCategoryUnselected: (CategoryInfo) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, + toRequestFocus: Boolean = false, +) { + LaunchedEffect(toRequestFocus) { + if (toRequestFocus) { + focusRequester.requestFocus() + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + modifier = modifier, + ) { + KeywordInput( + keyword = keyword, + onKeywordInput = onKeywordInput, + ) + CategorySelection( + categorySelectionList = categorySelectionList, + onCategorySelected = onCategorySelected, + onCategoryUnselected = onCategoryUnselected, + modifier = Modifier + .focusRestorer() + .focusRequester(focusRequester), + ) + } +} + +@Composable +private fun KeywordInput(keyword: String, onKeywordInput: (String) -> Unit, modifier: Modifier = Modifier) { + val textStyle = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant) + BasicTextField( + value = keyword, + onValueChange = onKeywordInput, + textStyle = textStyle, + cursorBrush = cursorBrush, + modifier = modifier, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(percent = 50), + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(id = R.drawable.ic_search), + contentDescription = stringResource(R.string.label_search), + modifier = Modifier.padding(end = 12.dp), + ) + innerTextField() + } + } + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun CategorySelection( + categorySelectionList: CategorySelectionList, + onCategorySelected: (CategoryInfo) -> Unit, + onCategoryUnselected: (CategoryInfo) -> Unit, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.chip), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.chip), + ) { + categorySelectionList.forEach { + FilterChip( + selected = it.isSelected, + onClick = { + if (it.isSelected) { + onCategoryUnselected(it.categoryInfo) + } else { + onCategorySelected(it.categoryInfo) + } + }, + ) { + Text(text = it.categoryInfo.name) + } + } + } +} + +@Composable +private fun SearchResult( + podcastList: PodcastList, + onPodcastSelected: (PodcastInfo) -> Unit, + header: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(4), + horizontalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), + modifier = modifier, + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + header() + } + items(podcastList) { + PodcastCard(podcastInfo = it, onClick = { onPodcastSelected(it) }) + } + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt new file mode 100644 index 0000000000..e6df198458 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.tv.model.CategoryInfoList +import com.example.jetcaster.tv.model.CategorySelection +import com.example.jetcaster.tv.model.CategorySelectionList +import com.example.jetcaster.tv.model.PodcastList +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class SearchScreenViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val podcastStore: PodcastStore, + categoryStore: CategoryStore, +) : ViewModel() { + + private val keywordFlow = MutableStateFlow("") + private val selectedCategoryListFlow = MutableStateFlow>(emptyList()) + + private val categoryInfoListFlow = + categoryStore.categoriesSortedByPodcastCount().map(CategoryInfoList::from) + + private val searchConditionFlow = + combine( + keywordFlow, + selectedCategoryListFlow, + categoryInfoListFlow, + ) { keyword, selectedCategories, categories -> + val selected = selectedCategories.ifEmpty { + categories + } + SearchCondition(keyword, selected) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val searchResultFlow = searchConditionFlow.flatMapLatest { + podcastStore.searchPodcastByTitleAndCategories( + it.keyword, + it.selectedCategories.intoCategoryList(), + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + emptyList(), + ) + + private val categorySelectionFlow = + combine( + categoryInfoListFlow, + selectedCategoryListFlow, + ) { categoryList, selectedCategories -> + val list = categoryList.map { + CategorySelection(it, selectedCategories.contains(it)) + } + CategorySelectionList(list) + } + + val uiStateFlow = + combine( + keywordFlow, + categorySelectionFlow, + searchResultFlow, + ) { keyword, categorySelection, result -> + val podcastList = result.map { it.asExternalModel() } + when { + result.isEmpty() -> SearchScreenUiState.Ready(keyword, categorySelection) + else -> SearchScreenUiState.HasResult(keyword, categorySelection, podcastList) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + SearchScreenUiState.Loading, + ) + + fun setKeyword(keyword: String) { + keywordFlow.value = keyword + } + + fun addCategoryToSelectedCategoryList(category: CategoryInfo) { + val list = selectedCategoryListFlow.value + if (!list.contains(category)) { + selectedCategoryListFlow.value = list + listOf(category) + } + } + + fun removeCategoryFromSelectedCategoryList(category: CategoryInfo) { + val list = selectedCategoryListFlow.value + if (list.contains(category)) { + val mutable = list.toMutableList() + mutable.remove(category) + selectedCategoryListFlow.value = mutable.toList() + } + } + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } +} + +private data class SearchCondition(val keyword: String, val selectedCategories: CategoryInfoList) { + constructor(keyword: String, categoryInfoList: List) : this( + keyword, + CategoryInfoList(categoryInfoList), + ) +} + +sealed interface SearchScreenUiState { + data object Loading : SearchScreenUiState + data class Ready(val keyword: String, val categorySelectionList: CategorySelectionList) : SearchScreenUiState + + data class HasResult(val keyword: String, val categorySelectionList: CategorySelectionList, val result: PodcastList) : + SearchScreenUiState +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000000..c7a54f16d4 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.component.NotAvailableFeature + +@Composable +fun SettingsScreen(modifier: Modifier = Modifier) { + NotAvailableFeature(modifier = modifier) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Color.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Color.kt new file mode 100644 index 0000000000..e01c77c91b --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Color.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.theme + +import androidx.tv.material3.darkColorScheme +import androidx.tv.material3.lightColorScheme +import com.example.jetcaster.designsystem.theme.backgroundDark +import com.example.jetcaster.designsystem.theme.backgroundLight +import com.example.jetcaster.designsystem.theme.errorContainerDark +import com.example.jetcaster.designsystem.theme.errorContainerLight +import com.example.jetcaster.designsystem.theme.errorDark +import com.example.jetcaster.designsystem.theme.errorLight +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight +import com.example.jetcaster.designsystem.theme.inversePrimaryDark +import com.example.jetcaster.designsystem.theme.inversePrimaryLight +import com.example.jetcaster.designsystem.theme.inverseSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseSurfaceLight +import com.example.jetcaster.designsystem.theme.onBackgroundDark +import com.example.jetcaster.designsystem.theme.onBackgroundLight +import com.example.jetcaster.designsystem.theme.onErrorContainerDark +import com.example.jetcaster.designsystem.theme.onErrorContainerLight +import com.example.jetcaster.designsystem.theme.onErrorDark +import com.example.jetcaster.designsystem.theme.onErrorLight +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight +import com.example.jetcaster.designsystem.theme.onPrimaryDark +import com.example.jetcaster.designsystem.theme.onPrimaryLight +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight +import com.example.jetcaster.designsystem.theme.onSecondaryDark +import com.example.jetcaster.designsystem.theme.onSecondaryLight +import com.example.jetcaster.designsystem.theme.onSurfaceDark +import com.example.jetcaster.designsystem.theme.onSurfaceLight +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight +import com.example.jetcaster.designsystem.theme.onTertiaryDark +import com.example.jetcaster.designsystem.theme.onTertiaryLight +import com.example.jetcaster.designsystem.theme.outlineDark +import com.example.jetcaster.designsystem.theme.outlineLight +import com.example.jetcaster.designsystem.theme.outlineVariantDark +import com.example.jetcaster.designsystem.theme.outlineVariantLight +import com.example.jetcaster.designsystem.theme.primaryContainerDark +import com.example.jetcaster.designsystem.theme.primaryContainerLight +import com.example.jetcaster.designsystem.theme.primaryDark +import com.example.jetcaster.designsystem.theme.primaryLight +import com.example.jetcaster.designsystem.theme.scrimDark +import com.example.jetcaster.designsystem.theme.scrimLight +import com.example.jetcaster.designsystem.theme.secondaryContainerDark +import com.example.jetcaster.designsystem.theme.secondaryContainerLight +import com.example.jetcaster.designsystem.theme.secondaryDark +import com.example.jetcaster.designsystem.theme.secondaryLight +import com.example.jetcaster.designsystem.theme.surfaceDark +import com.example.jetcaster.designsystem.theme.surfaceLight +import com.example.jetcaster.designsystem.theme.surfaceVariantDark +import com.example.jetcaster.designsystem.theme.surfaceVariantLight +import com.example.jetcaster.designsystem.theme.tertiaryContainerDark +import com.example.jetcaster.designsystem.theme.tertiaryContainerLight +import com.example.jetcaster.designsystem.theme.tertiaryDark +import com.example.jetcaster.designsystem.theme.tertiaryLight + +val colorSchemeForDarkMode = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + border = outlineDark, + borderVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, +) + +// Todo: specify surfaceTint +val colorSchemeForLightMode = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + border = outlineLight, + borderVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, +) diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt new file mode 100644 index 0000000000..ad8cdb0a19 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.theme + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp + +internal data object JetcasterAppDefaults { + val overScanMargin = OverScanMarginSettings() + val gap = GapSettings() + val cardWidth = CardWidth() + val padding = PaddingSettings() + val thumbnailSize = ThumbnailSize() + val iconButtonSize: IconButtonSize = IconButtonSize() +} + +internal data class OverScanMarginSettings( + val default: OverScanMargin = OverScanMargin(), + val catalog: OverScanMargin = OverScanMargin(end = 0.dp), + val episode: OverScanMargin = OverScanMargin(start = 80.dp, end = 80.dp), + val drawer: OverScanMargin = OverScanMargin(start = 16.dp, end = 16.dp), + val podcast: OverScanMargin = OverScanMargin( + top = 40.dp, + bottom = 40.dp, + start = 80.dp, + end = 80.dp, + ), + val player: OverScanMargin = OverScanMargin( + top = 40.dp, + bottom = 40.dp, + start = 80.dp, + end = 80.dp, + ), +) + +internal data class OverScanMargin(val top: Dp = 24.dp, val bottom: Dp = 24.dp, val start: Dp = 48.dp, val end: Dp = 48.dp) { + fun intoPaddingValues(): PaddingValues { + return PaddingValues(start, top, end, bottom) + } +} + +internal data class CardWidth(val large: Dp = 268.dp, val medium: Dp = 196.dp, val small: Dp = 124.dp) + +internal data class ThumbnailSize( + val episodeDetails: DpSize = DpSize(266.dp, 266.dp), + val podcast: DpSize = DpSize(196.dp, 196.dp), + val episode: DpSize = DpSize(124.dp, 124.dp), +) + +internal data class PaddingSettings( + val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), + val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp), + val podcastRowContentPadding: PaddingValues = PaddingValues(horizontal = 5.dp), + val episodeRowContentPadding: PaddingValues = PaddingValues(horizontal = 5.dp), +) + +internal data class GapSettings( + val tiny: Dp = 4.dp, + val small: Dp = tiny * 2, + val default: Dp = small * 2, + val medium: Dp = default + tiny, + val large: Dp = medium * 2, + + val chip: Dp = small, + val episodeRow: Dp = medium, + val item: Dp = default, + val paragraph: Dp = default, + val podcastRow: Dp = medium, + val section: Dp = large, + val twoColumn: Dp = large, +) + +internal data class IconButtonSize( + val default: Radius = Radius(14.dp), + val medium: Radius = Radius(20.dp), + val large: Radius = Radius(28.dp), +) + +internal data class Radius(private val value: Dp) { + private fun diameter(): Dp { + return value * 2 + } + fun intoDpSize(): DpSize { + val d = diameter() + return DpSize(d, d) + } +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.kt new file mode 100644 index 0000000000..c490ce6cd5 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.tv.material3.MaterialTheme + +@Composable +fun JetcasterTheme(isInDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val colorScheme = if (isInDarkTheme) { + colorSchemeForDarkMode + } else { + colorSchemeForLightMode + } + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Type.kt b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Type.kt new file mode 100644 index 0000000000..8075fe231d --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Type.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Typography +import com.example.jetcaster.designsystem.theme.Montserrat + +// Set of Material typography styles to start with +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + ), + displayMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 42.sp, + lineHeight = 52.sp, + ), + displaySmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + ), + headlineLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + ), + headlineMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineSmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + titleLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + titleSmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + labelSmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 11.sp, + lineHeight = 16.sp, + ), + bodyLarge = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + bodyMedium = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + bodySmall = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), +) diff --git a/Jetcaster/tv/src/main/res/drawable-nodpi/ic_text_logo.xml b/Jetcaster/tv/src/main/res/drawable-nodpi/ic_text_logo.xml new file mode 100644 index 0000000000..e422c1c25a --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable-nodpi/ic_text_logo.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/Jetcaster/tv/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Jetcaster/tv/src/main/res/drawable-v26/ic_launcher_foreground.xml new file mode 100644 index 0000000000..930f227590 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable-v26/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_add.xml b/Jetcaster/tv/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000000..2fcde96c96 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_forward_10.xml b/Jetcaster/tv/src/main/res/drawable/ic_forward_10.xml new file mode 100644 index 0000000000..7526b1a6b5 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_forward_10.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_home.xml b/Jetcaster/tv/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000000..9ef27eace1 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_info.xml b/Jetcaster/tv/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000000..1ece0341d0 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_launcher_background.xml b/Jetcaster/tv/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..7f2643db2d --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_launcher_foreground.xml b/Jetcaster/tv/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..c19b699858 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_launcher_monochrome.xml b/Jetcaster/tv/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..e71686aef8 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_library_music.xml b/Jetcaster/tv/src/main/res/drawable/ic_library_music.xml new file mode 100644 index 0000000000..790e3f6628 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_library_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_logo.xml b/Jetcaster/tv/src/main/res/drawable/ic_logo.xml new file mode 100644 index 0000000000..8d00d29968 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_pause.xml b/Jetcaster/tv/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000000..8a7990144b --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_person.xml b/Jetcaster/tv/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000000..aef9927129 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_person.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_play_arrow.xml b/Jetcaster/tv/src/main/res/drawable/ic_play_arrow.xml new file mode 100644 index 0000000000..582bcd142d --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_play_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_playlist_add.xml b/Jetcaster/tv/src/main/res/drawable/ic_playlist_add.xml new file mode 100644 index 0000000000..5022c69f3c --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_playlist_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_remove.xml b/Jetcaster/tv/src/main/res/drawable/ic_remove.xml new file mode 100644 index 0000000000..0cc9cffb46 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_replay_10.xml b/Jetcaster/tv/src/main/res/drawable/ic_replay_10.xml new file mode 100644 index 0000000000..f9826d9a05 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_replay_10.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_search.xml b/Jetcaster/tv/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000000..20c7b4e734 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_settings.xml b/Jetcaster/tv/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000000..0b0cc8700e --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_skip_next.xml b/Jetcaster/tv/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 0000000000..ba5057f655 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_skip_previous.xml b/Jetcaster/tv/src/main/res/drawable/ic_skip_previous.xml new file mode 100644 index 0000000000..a4a0b659c9 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_skip_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/drawable/ic_video_library.xml b/Jetcaster/tv/src/main/res/drawable/ic_video_library.xml new file mode 100644 index 0000000000..54ee520a83 --- /dev/null +++ b/Jetcaster/tv/src/main/res/drawable/ic_video_library.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetcaster/tv/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetcaster/tv/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..96e4ade2ed --- /dev/null +++ b/Jetcaster/tv/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/Jetcaster/tv/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Jetcaster/tv/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..96e4ade2ed --- /dev/null +++ b/Jetcaster/tv/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/Jetcaster/tv/src/main/res/mipmap-hdpi/ic_launcher.png b/Jetcaster/tv/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..1e97e1b9ec Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Jetcaster/tv/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Jetcaster/tv/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..1e97e1b9ec Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Jetcaster/tv/src/main/res/mipmap-mdpi/ic_launcher.png b/Jetcaster/tv/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..821e87fac3 Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Jetcaster/tv/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Jetcaster/tv/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..821e87fac3 Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Jetcaster/tv/src/main/res/mipmap-xhdpi/ic_launcher.png b/Jetcaster/tv/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..347493f918 Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Jetcaster/tv/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Jetcaster/tv/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..347493f918 Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/tv/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Jetcaster/tv/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..463f54c5d2 Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Jetcaster/tv/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Jetcaster/tv/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..463f54c5d2 Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/tv/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Jetcaster/tv/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..50721da443 Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Jetcaster/tv/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Jetcaster/tv/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..50721da443 Binary files /dev/null and b/Jetcaster/tv/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/tv/src/main/res/values/colors.xml b/Jetcaster/tv/src/main/res/values/colors.xml new file mode 100644 index 0000000000..fd3d732d7d --- /dev/null +++ b/Jetcaster/tv/src/main/res/values/colors.xml @@ -0,0 +1,20 @@ + + + + + #121212 + diff --git a/Jetcaster/tv/src/main/res/values/strings.xml b/Jetcaster/tv/src/main/res/values/strings.xml new file mode 100644 index 0000000000..23da33995c --- /dev/null +++ b/Jetcaster/tv/src/main/res/values/strings.xml @@ -0,0 +1,60 @@ + + + + JetCaster + This feature is not available yet. + Loading + Let\'s discover the podcasts! + You subscribe no podcast yet. Let\'s discover the podcasts and subscribe them! + Something wrong happened + No episode in the queue + Discover the Podcast you want to listen to + Podcast + Latest Episodes + Subscribe + Subscribed + Info + Play + Pause + Skip 10 seconds + Rewind 10 seconds + Play the next episode + Play the previous episode + Listen + Podcasts + Episodes + Latest Episodes + Discover the podcasts + Back to Home + Search podcasts by keyword + Add to playlist + + Updated a while ago + + Updated %d week ago + Updated %d weeks ago + + + Updated yesterday + Updated %d days ago + + Updated today + + %1$s • %2$d mins + %1$s • %2$s + %1$02d:%2$02d + \ No newline at end of file diff --git a/Jetcaster/tv/src/main/res/values/themes.xml b/Jetcaster/tv/src/main/res/values/themes.xml new file mode 100644 index 0000000000..295b149829 --- /dev/null +++ b/Jetcaster/tv/src/main/res/values/themes.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/Jetcaster/wear/src/main/res/values/colors.xml b/Jetcaster/wear/src/main/res/values/colors.xml new file mode 100644 index 0000000000..fd3d732d7d --- /dev/null +++ b/Jetcaster/wear/src/main/res/values/colors.xml @@ -0,0 +1,20 @@ + + + + + #121212 + diff --git a/Jetcaster/wear/src/main/res/values/dimens.xml b/Jetcaster/wear/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..9b16e76d95 --- /dev/null +++ b/Jetcaster/wear/src/main/res/values/dimens.xml @@ -0,0 +1,17 @@ + + + + + 48dp + diff --git a/Jetcaster/wear/src/main/res/values/strings.xml b/Jetcaster/wear/src/main/res/values/strings.xml new file mode 100644 index 0000000000..3494df240d --- /dev/null +++ b/Jetcaster/wear/src/main/res/values/strings.xml @@ -0,0 +1,83 @@ + + + + Jetcaster + + Connection error + Unable to fetch podcasts feeds.\nCheck your internet connection and try again. + Retry + + Podcasts + Latest episodes + + Your library + Queue + Up Next + Discover + Settings + Your library is empty. Checkout the latest podcasts. + Cancel + Refresh + + Change Speed + Download + Play episodes + Delete queue + Updated a while ago + + Updated %d week ago + Updated %d weeks ago + + + Updated yesterday + Updated %d days ago + + Updated today + + %1$s • %2$d mins + + Search + Account + Add + Back + More + Play + Skip previous + Reply 10 seconds + Forward 30 seconds + Skip next + Unfollow + Follow + Following + Not following + Nothing playing + + Speed + Increase playback speed + Decrease playback speed + Change playback speed + + No podcasts available at the moment + Loading + No episodes available at the moment + No title + Cancel + + No episode in the queue + Add an episode to the queue + There are no episodes from the queue + Add to queue + Episode info not available at the moment + + diff --git a/Jetcaster/wear/src/main/res/values/themes.xml b/Jetcaster/wear/src/main/res/values/themes.xml new file mode 100644 index 0000000000..c4dfa8ab7b --- /dev/null +++ b/Jetcaster/wear/src/main/res/values/themes.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/Jetcaster/wear/src/test/java/com/example/jetcaster/NavigationTest.kt b/Jetcaster/wear/src/test/java/com/example/jetcaster/NavigationTest.kt new file mode 100644 index 0000000000..bad6789298 --- /dev/null +++ b/Jetcaster/wear/src/test/java/com/example/jetcaster/NavigationTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster + +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NavigationTest { + @get:Rule + val rule = createAndroidComposeRule(MainActivity::class.java) + + @Test + fun launchAndNavigate() { + val activity = rule.activity + + val navController = activity.navController + + rule.waitUntil { + navController.currentDestination?.route != null + } + + assertEquals("player?page={page}", navController.currentDestination?.route) + + navController.navigateToUpNext() + + assertEquals("upNext", navController.currentDestination?.route) + } +} diff --git a/Jetchat/.editorconfig b/Jetchat/.editorconfig new file mode 100644 index 0000000000..e699cf2e28 --- /dev/null +++ b/Jetchat/.editorconfig @@ -0,0 +1,26 @@ +# When authoring changes in .editorconfig, run ./gradlew spotlessApply --no-daemon +# Reference: https://github.com/diffplug/spotless/issues/1924 +[*.{kt,kts}] +ktlint_code_style = android_studio +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +max_line_length = 140 # ktlint official +ktlint_function_naming_ignore_when_annotated_with = Composable, Test +ktlint_standard_filename = disabled +ktlint_standard_package-name = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_backing-property-naming = disabled +ktlint_standard_argument-list-wrapping = disabled +ktlint_standard_parameter-list-wrapping = disabled +ktlint_standard_double-colon-spacing = disabled +ktlint_standard_enum-entry-name-case = disabled +ktlint_standard_multiline-if-else = disabled +ktlint_standard_no-empty-first-line-in-method-block = disabled +ktlint_standard_package-name = disabled +ktlint_standard_trailing-comma = disabled +ktlint_standard_spacing-around-angle-brackets = disabled +ktlint_standard_spacing-between-declarations-with-annotations = disabled +ktlint_standard_spacing-between-declarations-with-comments = disabled +ktlint_standard_unary-op-spacing = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_value-parameter-comment = disabled diff --git a/Jetchat/.gitignore b/Jetchat/.gitignore index aa724b7707..834ecd9dff 100644 --- a/Jetchat/.gitignore +++ b/Jetchat/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +.kotlin/ diff --git a/Jetchat/.google/packaging.yaml b/Jetchat/.google/packaging.yaml index 84aff54914..b8e5ccd387 100644 --- a/Jetchat/.google/packaging.yaml +++ b/Jetchat/.google/packaging.yaml @@ -18,10 +18,23 @@ # End users may safely ignore this file. It has no relevance to other systems. --- status: PUBLISHED -technologies: [Android] -categories: [Compose] +technologies: [Android, JetpackCompose] +categories: + - AndroidArchitectureUILayer + - AndroidArchitectureStateProduction + - AndroidArchitectureUIEvents + - JetpackComposeArchitectureAndState + - JetpackComposeDesignSystems + - JetpackComposeAnimation + - JetpackComposeTextAndInput + - JetpackComposeTesting languages: [Kotlin] -solutions: [Mobile] +solutions: + - Mobile + - JetpackHilt + - JetpackLifecycle + - JetpackNavigation + - JetpackFragment github: android/compose-samples level: BEGINNER apiRefs: diff --git a/Jetchat/README.md b/Jetchat/README.md index 09187beece..0433a4585d 100644 --- a/Jetchat/README.md +++ b/Jetchat/README.md @@ -4,7 +4,8 @@ Jetchat is a sample chat app built with [Jetpack Compose][compose]. -To try out these sample apps, you need to use the latest Canary version of Android Studio 4.2. +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). You can clone this repository or import the project from Android Studio following the steps [here](https://developer.android.com/jetpack/compose/setup#sample). @@ -17,10 +18,16 @@ This sample showcases: * Text Input and focus management * Multiple types of animations and transitions * Saved state across configuration changes -* Basic Material Design theming +* Material Design 3 theming and Material You dynamic color * UI tests - +## Screenshots + + + + + + ### Status: 🚧 In progress @@ -36,10 +43,10 @@ The [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/Prof [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel), served via [LiveData](https://developer.android.com/topic/libraries/architecture/livedata). ### Back button handling -When the Emoji selector is shown, pressing back in the app closes it, intercepting any navigation events. This feature shows a way to integrate Compose and APIs from the Android Framework like [OnBackPressedDispatcherOwner](https://developer.android.com/reference/androidx/activity/OnBackPressedDispatcher) via [Ambients](https://developer.android.com/reference/kotlin/androidx/compose/Ambient). The implementation can be found in [ConversationUiState](app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt). +When the Emoji selector is shown, pressing back in the app closes it, intercepting any navigation events. The implementation can be found in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). ### Text Input and focus management -When the Emoji panel is shown the keyboard must be hidden and vice versa. This is achieved with a combination of the [FocusRequester](https://developer.android.com/reference/kotlin/androidx/compose/ui/focus/FocusRequester) and [FocusObserver](https://developer.android.com/reference/kotlin/androidx/compose/ui/FocusObserverModifier) APIs. +When the Emoji panel is shown the keyboard must be hidden and vice versa. This is achieved with a combination of the [FocusRequester](https://developer.android.com/reference/kotlin/androidx/compose/ui/focus/FocusRequester) and [onFocusChanged](https://developer.android.com/reference/kotlin/androidx/compose/ui/focus/package-summary#(androidx.compose.ui.Modifier).onFocusChanged(kotlin.Function1)) APIs. ### Multiple types of animations and transitions This sample uses animations ranging from simple `AnimatedVisibility` in [FunctionalityNotAvailablePanel](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) to choreographed transitions found in the [FloatingActionButton](https://material.io/develop/android/components/floating-action-button) of the Profile screen and implemented in [AnimatingFabContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) @@ -47,30 +54,16 @@ This sample uses animations ranging from simple `AnimatedVisibility` in [Functio ### Edge-to-edge UI with synchronized IME transitions This sample is laid out [edge-to-edge](https://medium.com/androiddevelopers/gesture-navigation-going-edge-to-edge-812f62e4e83e), drawing its content behind the system bars for a more immersive look. -The sample also supports synchronized IME transitions when running on API 30+ devices. See the use of `Modifier.navigationBarsWithImePadding()` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). - - - -The sample uses the -[Accompanist Insets library](https://google.github.io/accompanist/insets/) for WindowInsets support. +The sample also supports synchronized IME transitions when running on API 30+ devices. See the use of `Modifier.navigationBarsPadding().imePadding()` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). ### Saved state across configuration changes Some composable state survives activity or process recreation, like `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). -### Basic Material Design theming -Jetchat follows the Material Design principles and uses the `MaterialTheme` ambient, with custom light and dark themes. In some cases colors it might be necessary to create additional colors, that can be specified as an overlay or combination of two, or as a specific elevation in dark mode. Jetchat uses some convenient extensions on the Material palette and can be used as follows: - -[UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) -```kotlin -@Composable -fun getSelectorExpandedColor(): Color { - return if (MaterialTheme.colors.isLight) { - MaterialTheme.colors.compositedOnSurface(0.04f) - } else { - MaterialTheme.colors.elevatedSurface(8.dp) - } -} -``` +### Material Design 3 theming and Material You dynamic color +Jetchat follows the [Material Design 3](https://m3.material.io) principles and uses the `MaterialTheme` composable and M3 components. On Android 12+ Jetchat supports Material You dynamic color, which extracts a custom color scheme from the device wallpaper. Jetchat uses a custom, branded color scheme as a fallback. It also implements custom typography using the Karla and Montserrat font families. + +### Nested scrolling interop +Jetchat contains an example of how to use [`rememberNestedScrollInteropConnection()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/package-summary#rememberNestedScrollInteropConnection()) to achieve successful nested scroll interop between a View parent that implements `androidx.core.view.NestedScrollingParent3` and a Compose child. The example used here is a combination of a View parent `CoordinatorLayout` and a nested, Compose child `BoxWithConstraints` in [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt). ### UI tests In [androidTest](app/src/androidTest/java/com/example/compose/jetchat) you'll find a suite of UI tests that showcase interesting patterns in Compose: @@ -109,4 +102,3 @@ limitations under the License. ``` [compose]: https://developer.android.com/jetpack/compose -[coil-accompanist]: https://google.github.io/accompanist/coil/ diff --git a/Jetchat/app/build.gradle b/Jetchat/app/build.gradle deleted file mode 100644 index 8d4c282f8a..0000000000 --- a/Jetchat/app/build.gradle +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.compose.jetchat.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' -} - -android { - compileSdkVersion 30 - - defaultConfig { - applicationId "com.example.compose.jetchat" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName '1.0' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - - vectorDrawables.useSupportLibrary = true - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - buildFeatures { - compose true - viewBinding true - - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } - - packagingOptions { - exclude "META-INF/licenses/**" - exclude "META-INF/AL2.0" - exclude "META-INF/LGPL2.1" - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - - implementation Libs.AndroidX.Activity.activityCompose - implementation Libs.AndroidX.coreKtx - implementation Libs.AndroidX.appcompat - implementation Libs.AndroidX.Lifecycle.livedata - implementation Libs.AndroidX.Lifecycle.viewModelCompose - implementation Libs.AndroidX.Navigation.fragment - implementation Libs.AndroidX.Navigation.uiKtx - implementation Libs.material - - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.materialIconsExtended - implementation Libs.AndroidX.Compose.tooling - implementation Libs.AndroidX.Compose.uiUtil - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.runtimeLivedata - implementation Libs.AndroidX.Compose.viewBinding - - implementation Libs.Accompanist.insets - - androidTestImplementation Libs.junit - androidTestImplementation Libs.AndroidX.Test.core - androidTestImplementation Libs.AndroidX.Test.espressoCore - androidTestImplementation Libs.AndroidX.Test.rules - androidTestImplementation Libs.AndroidX.Test.Ext.junit - androidTestImplementation Libs.AndroidX.Compose.uiTest - - // androidx.test is forcing JUnit, 4.12. This forces it to use 4.13 - configurations.configureEach { - resolutionStrategy { - force Libs.junit - } - } -} diff --git a/Jetchat/app/build.gradle.kts b/Jetchat/app/build.gradle.kts new file mode 100644 index 0000000000..4cdae3e2dd --- /dev/null +++ b/Jetchat/app/build.gradle.kts @@ -0,0 +1,128 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "com.example.compose.jetchat" + + defaultConfig { + applicationId = "com.example.compose.jetchat" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + vectorDrawables.useSupportLibrary = true + } + + signingConfigs { + // Important: change the keystore for a production deployment + val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore") + val localKeystore = rootProject.file("debug_2.keystore") + val hasKeyInfo = userKeystore.exists() + create("release") { + storeFile = if (hasKeyInfo) userKeystore else localKeystore + storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password") + keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias") + keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password") + } + } + + buildTypes { + getByName("debug") { + } + + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + viewBinding = true + } + + packaging.resources { + // Multiple dependency bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.material3) + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.androidx.activity.compose) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.ui.ktx) + + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui.util) + implementation(libs.androidx.compose.ui.viewbinding) + implementation(libs.androidx.compose.ui.googlefonts) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) +} diff --git a/Jetchat/app/proguard-rules.pro b/Jetchat/app/proguard-rules.pro index 4cb94585a0..9e6e059b3b 100644 --- a/Jetchat/app/proguard-rules.pro +++ b/Jetchat/app/proguard-rules.pro @@ -22,3 +22,17 @@ # Repackage classes into the top-level. -repackageclasses + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE + + +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } diff --git a/Jetchat/app/src/androidTest/AndroidManifest.xml b/Jetchat/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..f7b8095f4a --- /dev/null +++ b/Jetchat/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt index 11ee61199a..617a4bd295 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt @@ -17,26 +17,21 @@ package com.example.compose.jetchat import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.center -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performGesture +import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipe +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.compose.jetchat.conversation.ConversationContent import com.example.compose.jetchat.conversation.ConversationTestTag import com.example.compose.jetchat.conversation.ConversationUiState -import com.example.compose.jetchat.conversation.LocalBackPressedDispatcher import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.WindowInsets import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule @@ -54,23 +49,14 @@ class ConversationTest { @Before fun setUp() { - // Provide empty insets. We can modify this value as necessary - val windowInsets = WindowInsets() - // Launch the conversation screen composeTestRule.setContent { - val onBackPressedDispatcher = composeTestRule.activity.onBackPressedDispatcher - CompositionLocalProvider( - LocalBackPressedDispatcher provides onBackPressedDispatcher, - LocalWindowInsets provides windowInsets - ) { - JetchatTheme(isDarkTheme = themeIsDark.collectAsState(false).value) { - ConversationContent( - uiState = conversationTestUiState, - navigateToProfile = { }, - onNavIconPressed = { } - ) - } + JetchatTheme(isDarkTheme = themeIsDark.collectAsStateWithLifecycle(false).value) { + ConversationContent( + uiState = conversationTestUiState, + navigateToProfile = { }, + onNavIconPressed = { }, + ) } } } @@ -85,11 +71,11 @@ class ConversationTest { fun userScrollsUp_jumpToBottomAppears() { // Check list is snapped to bottom and swipe up findJumpToBottom().assertDoesNotExist() - composeTestRule.onNodeWithTag(ConversationTestTag).performGesture { + composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { this.swipe( start = this.center, end = Offset(this.center.x, this.center.y + 500), - durationMillis = 200 + durationMillis = 200, ) } // Check that the jump to bottom button is shown @@ -99,11 +85,11 @@ class ConversationTest { @Test fun jumpToBottom_snapsToBottomAndDisappears() { // When the scroll is not snapped to the bottom - composeTestRule.onNodeWithTag(ConversationTestTag).performGesture { + composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { this.swipe( start = this.center, end = Offset(this.center.x, this.center.y + 500), - durationMillis = 200 + durationMillis = 200, ) } // Snap scroll to the bottom @@ -116,11 +102,14 @@ class ConversationTest { @Test fun jumpToBottom_snapsToBottomAfterUserInteracted() { // First swipe - composeTestRule.onNodeWithTag(ConversationTestTag).performGesture { + composeTestRule.onNodeWithTag( + testTag = ConversationTestTag, + useUnmergedTree = true, // https://issuetracker.google.com/issues/184825850 + ).performTouchInput { this.swipe( start = this.center, end = Offset(this.center.x, this.center.y + 500), - durationMillis = 200 + durationMillis = 200, ) } // Second, snap to bottom @@ -136,11 +125,11 @@ class ConversationTest { @Test fun changeTheme_scrollIsPersisted() { // Swipe to show the jump to bottom button - composeTestRule.onNodeWithTag(ConversationTestTag).performGesture { + composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { this.swipe( start = this.center, end = Offset(this.center.x, this.center.y + 500), - durationMillis = 200 + durationMillis = 200, ) } @@ -154,15 +143,17 @@ class ConversationTest { findJumpToBottom().assertIsDisplayed() } - private fun findJumpToBottom() = - composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.jumpBottom)) - - private fun openEmojiSelector() = - composeTestRule - .onNodeWithContentDescription( - composeTestRule.activity.getString(R.string.emoji_selector_bt_desc) - ) - .performClick() + private fun findJumpToBottom() = composeTestRule.onNodeWithText( + composeTestRule.activity.getString(R.string.jumpBottom), + useUnmergedTree = true, + ) + + private fun openEmojiSelector() = composeTestRule + .onNodeWithContentDescription( + label = composeTestRule.activity.getString(R.string.emoji_selector_bt_desc), + useUnmergedTree = true, // https://issuetracker.google.com/issues/184825850 + ) + .performClick() } /** @@ -171,5 +162,5 @@ class ConversationTest { private val conversationTestUiState = ConversationUiState( initialMessages = (exampleUiState.messages.plus(exampleUiState.messages)), channelName = "#composers", - channelMembers = 42 + channelMembers = 42, ) diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt index e935bb4f16..be0f3b14b5 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt @@ -16,14 +16,19 @@ package com.example.compose.jetchat +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.navigation.NavController import androidx.navigation.findNavController import androidx.test.espresso.Espresso import org.junit.Assert.assertEquals -import org.junit.Ignore import org.junit.Rule import org.junit.Test @@ -42,13 +47,10 @@ class NavigationTest { } @Test - @Ignore("Issue with keyboard sync https://issuetracker.google.com/169235317") fun profileScreen_back_conversationScreen() { val navController = getNavController() - // Navigate to profile - composeTestRule.runOnUiThread { - navController.navigate(R.id.nav_profile) - } + // Navigate to profile \ + navigateToProfile("Taylor Brooks") // Check profile is displayed assertEquals(navController.currentDestination?.id, R.id.nav_profile) // Extra UI check @@ -63,6 +65,43 @@ class NavigationTest { assertEquals(navController.currentDestination?.id, R.id.nav_home) } + /** + * Regression test for https://github.com/android/compose-samples/issues/670 + */ + @Test + fun drawer_conversationScreen_backstackPopUp() { + navigateToProfile("Ali Conors (you)") + navigateToHome() + navigateToProfile("Taylor Brooks") + navigateToHome() + + // Chewie, we're home + assertEquals(getNavController().currentDestination?.id, R.id.nav_home) + } + + private fun navigateToProfile(name: String) { + composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.navigation_drawer_open), + ).performClick() + + composeTestRule.onNode(hasText(name) and isInDrawer()).performClick() + } + + private fun isInDrawer() = hasAnyAncestor(isDrawer()) + + private fun isDrawer() = SemanticsMatcher.expectValue( + SemanticsProperties.PaneTitle, + composeTestRule.activity.getString(androidx.compose.ui.R.string.navigation_menu), + ) + + private fun navigateToHome() { + composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.navigation_drawer_open), + ).performClick() + + composeTestRule.onNode(hasText("composers") and isInDrawer()).performClick() + } + private fun getNavController(): NavController { return composeTestRule.activity.findNavController(R.id.nav_host_fragment) } diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt index ba244362cb..4c5865190a 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt @@ -17,7 +17,6 @@ package com.example.compose.jetchat import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsDisplayed @@ -26,7 +25,7 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -35,11 +34,8 @@ import androidx.compose.ui.test.performTextInput import androidx.test.espresso.Espresso import com.example.compose.jetchat.conversation.ConversationContent import com.example.compose.jetchat.conversation.KeyboardShownKey -import com.example.compose.jetchat.conversation.LocalBackPressedDispatcher import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.WindowInsets import org.junit.Before import org.junit.Ignore import org.junit.Rule @@ -57,24 +53,14 @@ class UserInputTest { @Before fun setUp() { - - // Provide empty insets. We can modify this value as necessary - val windowInsets = WindowInsets() - // Launch the conversation screen - val onBackPressedDispatcher = composeTestRule.activity.onBackPressedDispatcher composeTestRule.setContent { - CompositionLocalProvider( - LocalBackPressedDispatcher provides onBackPressedDispatcher, - LocalWindowInsets provides windowInsets, - ) { - JetchatTheme { - ConversationContent( - uiState = exampleUiState, - navigateToProfile = { }, - onNavIconPressed = { } - ) - } + JetchatTheme { + ConversationContent( + uiState = exampleUiState, + navigateToProfile = { }, + onNavIconPressed = { }, + ) } } } @@ -147,32 +133,31 @@ class UserInputTest { findSendButton().assertIsEnabled() } - private fun clickOnTextField() = - composeTestRule - .onNodeWithContentDescription(activity.getString(R.string.textfield_desc)) - .performClick() + private fun clickOnTextField() = composeTestRule + .onNodeWithContentDescription(activity.getString(R.string.textfield_desc)) + .performClick() - private fun openEmojiSelector() = - composeTestRule - .onNodeWithContentDescription(activity.getString(R.string.emoji_selector_bt_desc)) - .performClick() + private fun openEmojiSelector() = composeTestRule + .onNodeWithContentDescription( + label = activity.getString(R.string.emoji_selector_bt_desc), + useUnmergedTree = true, // https://issuetracker.google.com/issues/184825850 + ) + .performClick() - private fun assertEmojiSelectorIsDisplayed() = - composeTestRule - .onNodeWithContentDescription(activity.getString(R.string.emoji_selector_desc)) - .assertIsDisplayed() + private fun assertEmojiSelectorIsDisplayed() = composeTestRule + .onNodeWithContentDescription(activity.getString(R.string.emoji_selector_desc)) + .assertIsDisplayed() - private fun assertEmojiSelectorDoesNotExist() = - composeTestRule - .onNodeWithContentDescription(activity.getString(R.string.emoji_selector_desc)) - .assertDoesNotExist() + private fun assertEmojiSelectorDoesNotExist() = composeTestRule + .onNodeWithContentDescription(activity.getString(R.string.emoji_selector_desc)) + .assertDoesNotExist() private fun findSendButton() = composeTestRule.onNodeWithText(activity.getString(R.string.send)) private fun findTextInputField(): SemanticsNodeInteraction { return composeTestRule.onNode( hasSetTextAction() and - hasAnyAncestor(hasContentDescription(activity.getString(R.string.textfield_desc))) + hasAnyAncestor(hasContentDescription(activity.getString(R.string.textfield_desc))), ) } } diff --git a/Jetchat/app/src/debug/AndroidManifest.xml b/Jetchat/app/src/debug/AndroidManifest.xml deleted file mode 100644 index f453702527..0000000000 --- a/Jetchat/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - diff --git a/Jetchat/app/src/main/AndroidManifest.xml b/Jetchat/app/src/main/AndroidManifest.xml index d0f258d68b..b4ea01d944 100644 --- a/Jetchat/app/src/main/AndroidManifest.xml +++ b/Jetchat/app/src/main/AndroidManifest.xml @@ -15,24 +15,34 @@ ~ limitations under the License. --> - + + + android:exported="true"> + + + + + + diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/FragmentAwareAndroidViewBinding.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/FragmentAwareAndroidViewBinding.kt deleted file mode 100644 index 48efc2e072..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/FragmentAwareAndroidViewBinding.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.viewinterop.AndroidViewBinding -import androidx.core.view.forEach -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentContainerView -import androidx.fragment.app.commit -import androidx.fragment.app.findFragment -import androidx.viewbinding.ViewBinding - -/** - * TODO: Fragments inflated via AndroidViewBinding don't work as expected - * https://issuetracker.google.com/179915946 - */ -@Composable -fun FragmentAwareAndroidViewBinding( - bindingBlock: (LayoutInflater, ViewGroup, Boolean) -> T, - modifier: Modifier = Modifier, - update: T.() -> Unit = {} -) { - val fragmentContainerViews = remember { mutableStateListOf() } - AndroidViewBinding(bindingBlock, modifier = modifier) { - fragmentContainerViews.clear() - val rootGroup = root as? ViewGroup - if (rootGroup != null) { - findFragmentContainerViews(rootGroup, fragmentContainerViews) - } - update() - } - val activity = LocalContext.current as FragmentActivity - fragmentContainerViews.forEach { container -> - DisposableEffect(container) { - // Find the right FragmentManager - val fragmentManager = try { - val parentFragment = container.findFragment() - parentFragment.childFragmentManager - } catch (e: Exception) { - activity.supportFragmentManager - } - // Now find the fragment inflated via the FragmentContainerView - val existingFragment = fragmentManager.findFragmentById(R.id.nav_host_fragment) - if (existingFragment != null) { - val fragmentView = existingFragment.requireView() - // Remove the Fragment from whatever old parent it had - // (this is most likely an old binding if it is non-null) - (fragmentView.parent as? ViewGroup)?.run { - removeView(fragmentView) - } - // Re-add it to the layout if it was moved to RESUMED before - // this Composable ran - container.addView(existingFragment.requireView()) - } - onDispose { - if (existingFragment != null && !fragmentManager.isStateSaved) { - // If the state isn't saved, that means that some state change - // has removed this Composable from the hierarchy - fragmentManager.commit { - remove(existingFragment) - } - } - } - } - } -} - -private fun findFragmentContainerViews( - viewGroup: ViewGroup, - list: SnapshotStateList -) { - viewGroup.forEach { - if (it is FragmentContainerView) { - list += it - } else if (it is ViewGroup) { - findFragmentContainerViews(it, list) - } - } -} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt index c89e7c33e1..51bb6d040a 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt @@ -16,22 +16,22 @@ package com.example.compose.jetchat -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow /** * Used to communicate between screens. */ class MainViewModel : ViewModel() { - // TODO: Expose a flow for events - private val _drawerShouldBeOpened = MutableLiveData(false) - val drawerShouldBeOpened: LiveData = _drawerShouldBeOpened + private val _drawerShouldBeOpened = MutableStateFlow(false) + val drawerShouldBeOpened = _drawerShouldBeOpened.asStateFlow() fun openDrawer() { _drawerShouldBeOpened.value = true } + fun resetOpenDrawerAction() { _drawerShouldBeOpened.value = false } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt index c1a9153255..dff10570d9 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt @@ -17,24 +17,28 @@ package com.example.compose.jetchat import android.os.Bundle -import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.material3.DrawerValue.Closed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.viewinterop.AndroidViewBinding import androidx.core.os.bundleOf -import androidx.core.view.WindowCompat +import androidx.core.view.ViewCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController -import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment -import com.example.compose.jetchat.components.JetchatScaffold -import com.example.compose.jetchat.conversation.BackPressHandler -import com.example.compose.jetchat.conversation.LocalBackPressedDispatcher +import com.example.compose.jetchat.components.JetchatDrawer import com.example.compose.jetchat.databinding.ContentMainBinding -import com.google.accompanist.insets.ProvideWindowInsets import kotlinx.coroutines.launch /** @@ -43,65 +47,59 @@ import kotlinx.coroutines.launch class NavActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> insets } - // Turn off the decor fitting system windows, which allows us to handle insets, - // including IME animations - WindowCompat.setDecorFitsSystemWindows(window, false) + setContentView( + ComposeView(this).apply { + consumeWindowInsets = false + setContent { + val drawerState = rememberDrawerState(initialValue = Closed) + val drawerOpen by viewModel.drawerShouldBeOpened + .collectAsStateWithLifecycle() - setContent { - // Provide WindowInsets to our content. We don't want to consume them, so that - // they keep being pass down the view hierarchy (since we're using fragments). - ProvideWindowInsets(consumeWindowInsets = false) { - CompositionLocalProvider( - LocalBackPressedDispatcher provides this.onBackPressedDispatcher - ) { - val scaffoldState = rememberScaffoldState() - - val openDrawerEvent = viewModel.drawerShouldBeOpened.observeAsState() - if (openDrawerEvent.value == true) { + var selectedMenu by remember { mutableStateOf("composers") } + if (drawerOpen) { // Open drawer and reset state in VM. LaunchedEffect(Unit) { - scaffoldState.drawerState.open() - viewModel.resetOpenDrawerAction() + // wrap in try-finally to handle interruption whiles opening drawer + try { + drawerState.open() + } finally { + viewModel.resetOpenDrawerAction() + } } } - // Intercepts back navigation when the drawer is open val scope = rememberCoroutineScope() - if (scaffoldState.drawerState.isOpen) { - BackPressHandler { - scope.launch { - scaffoldState.drawerState.close() - } - } - } - JetchatScaffold( - scaffoldState, + JetchatDrawer( + drawerState = drawerState, + selectedMenu = selectedMenu, onChatClicked = { - findNavController().popBackStack(R.id.nav_home, true) + findNavController().popBackStack(R.id.nav_home, false) scope.launch { - scaffoldState.drawerState.close() + drawerState.close() } + selectedMenu = it }, onProfileClicked = { val bundle = bundleOf("userId" to it) findNavController().navigate(R.id.nav_profile, bundle) scope.launch { - scaffoldState.drawerState.close() + drawerState.close() } - } + selectedMenu = it + }, ) { - // TODO: Fragments inflated via AndroidViewBinding don't work as expected - // https://issuetracker.google.com/179915946 - // AndroidViewBinding(ContentMainBinding::inflate) - FragmentAwareAndroidViewBinding(ContentMainBinding::inflate) + AndroidViewBinding(ContentMainBinding::inflate) } } - } - } + }, + ) } override fun onSupportNavigateUp(): Boolean { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/UiExtras.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/UiExtras.kt index ff3ae21393..3d9f0c9064 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/UiExtras.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/UiExtras.kt @@ -16,10 +16,10 @@ package com.example.compose.jetchat -import androidx.compose.material.AlertDialog -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextButton +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @Composable @@ -29,13 +29,13 @@ fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) { text = { Text( text = "Functionality not available \uD83D\uDE48", - style = MaterialTheme.typography.body2 + style = MaterialTheme.typography.bodyMedium, ) }, confirmButton = { TextButton(onClick = onDismiss) { Text(text = "CLOSE") } - } + }, ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt index 629fbf8cba..a624834067 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.Layout import androidx.compose.ui.util.lerp import kotlin.math.roundToInt @@ -39,28 +40,29 @@ fun AnimatingFabContent( icon: @Composable () -> Unit, text: @Composable () -> Unit, modifier: Modifier = Modifier, - extended: Boolean = true + extended: Boolean = true, ) { val currentState = if (extended) ExpandableFabStates.Extended else ExpandableFabStates.Collapsed - val transition = updateTransition(currentState) + val transition = updateTransition(currentState, "fab_transition") val textOpacity by transition.animateFloat( transitionSpec = { if (targetState == ExpandableFabStates.Collapsed) { tween( easing = LinearEasing, - durationMillis = (transitionDuration / 12f * 5).roundToInt() // 5 / 12 frames + durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames ) } else { tween( easing = LinearEasing, delayMillis = (transitionDuration / 3f).roundToInt(), // 4 / 12 frames - durationMillis = (transitionDuration / 12f * 5).roundToInt() // 5 / 12 frames + durationMillis = (transitionDuration / 12f * 5).roundToInt(), // 5 / 12 frames ) } - } - ) { progress -> - if (progress == ExpandableFabStates.Collapsed) { + }, + label = "fab_text_opacity", + ) { state -> + if (state == ExpandableFabStates.Collapsed) { 0f } else { 1f @@ -71,29 +73,31 @@ fun AnimatingFabContent( if (targetState == ExpandableFabStates.Collapsed) { tween( easing = FastOutSlowInEasing, - durationMillis = transitionDuration + durationMillis = transitionDuration, ) } else { tween( easing = FastOutSlowInEasing, - durationMillis = transitionDuration + durationMillis = transitionDuration, ) } - } - ) { progress -> - if (progress == ExpandableFabStates.Collapsed) { + }, + label = "fab_width_factor", + ) { state -> + if (state == ExpandableFabStates.Collapsed) { 0f } else { 1f } } - // Using functions instead of Floats here can improve performance, preventing recompositions. + // Deferring reads using lambdas instead of Floats here can improve performance, + // preventing recompositions. IconAndTextRow( icon, text, { textOpacity }, { fabWidthFactor }, - modifier = modifier + modifier = modifier, ) } @@ -101,18 +105,18 @@ fun AnimatingFabContent( private fun IconAndTextRow( icon: @Composable () -> Unit, text: @Composable () -> Unit, - opacityProgress: () -> Float, // Functions instead of Floats, to slightly improve performance + opacityProgress: () -> Float, // Lambdas instead of Floats, to defer read widthProgress: () -> Float, - modifier: Modifier + modifier: Modifier, ) { Layout( modifier = modifier, content = { icon() - Box(modifier = Modifier.alpha(opacityProgress())) { + Box(modifier = Modifier.graphicsLayer { alpha = opacityProgress() }) { text() } - } + }, ) { measurables, constraints -> val iconPlaceable = measurables[0].measure(constraints) @@ -135,11 +139,11 @@ private fun IconAndTextRow( layout(width.roundToInt(), height) { iconPlaceable.place( iconPadding.roundToInt(), - constraints.maxHeight / 2 - iconPlaceable.height / 2 + constraints.maxHeight / 2 - iconPlaceable.height / 2, ) textPlaceable.place( (iconPlaceable.width + iconPadding * 2).roundToInt(), - constraints.maxHeight / 2 - textPlaceable.height / 2 + constraints.maxHeight / 2 - textPlaceable.height / 2, ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/BaseLineHeightModifier.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/BaseLineHeightModifier.kt index c0f731aa0d..d3b5596d2c 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/BaseLineHeightModifier.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/BaseLineHeightModifier.kt @@ -39,14 +39,9 @@ import androidx.compose.ui.unit.Dp * This modifier can be used to distribute multiple text elements using a certain distance between * baselines. */ -data class BaselineHeightModifier( - val heightFromBaseline: Dp -) : LayoutModifier { +data class BaselineHeightModifier(val heightFromBaseline: Dp) : LayoutModifier { - override fun MeasureScope.measure( - measurable: Measurable, - constraints: Constraints - ): MeasureResult { + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult { val textPlaceable = measurable.measure(constraints) val firstBaseline = textPlaceable[FirstBaseline] @@ -60,5 +55,4 @@ data class BaselineHeightModifier( } } -fun Modifier.baselineHeight(heightFromBaseline: Dp): Modifier = - this.then(BaselineHeightModifier(heightFromBaseline)) +fun Modifier.baselineHeight(heightFromBaseline: Dp): Modifier = this.then(BaselineHeightModifier(heightFromBaseline)) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt index 45f32c8194..86250f1187 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt @@ -14,68 +14,53 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.example.compose.jetchat.components -import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.compose.jetchat.R import com.example.compose.jetchat.theme.JetchatTheme -import com.example.compose.jetchat.theme.elevatedSurface +@OptIn(ExperimentalMaterial3Api::class) @Composable fun JetchatAppBar( modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = null, onNavIconPressed: () -> Unit = { }, - title: @Composable RowScope.() -> Unit, - actions: @Composable RowScope.() -> Unit = {} + title: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, ) { - // This bar is translucent but elevation overlays are not applied to translucent colors. - // Instead we manually calculate the elevated surface color from the opaque color, - // then apply our alpha. - // - // We set the background on the Column rather than the TopAppBar, - // so that the background is drawn behind any padding set on the app bar (i.e. status bar). - val backgroundColor = MaterialTheme.colors.elevatedSurface(3.dp) - Column( - Modifier.background(backgroundColor.copy(alpha = 0.95f)) - ) { - TopAppBar( - modifier = modifier, - backgroundColor = Color.Transparent, - elevation = 0.dp, // No shadow needed - contentColor = MaterialTheme.colors.onSurface, - actions = actions, - title = { Row { title() } }, // https://issuetracker.google.com/168793068 - navigationIcon = { - Image( - painter = painterResource(id = R.drawable.ic_jetchat), - contentDescription = stringResource(id = R.string.back), - modifier = Modifier - .clickable(onClick = onNavIconPressed) - .padding(horizontal = 16.dp) - ) - } - ) - Divider() - } + CenterAlignedTopAppBar( + modifier = modifier, + actions = actions, + title = title, + scrollBehavior = scrollBehavior, + navigationIcon = { + JetchatIcon( + contentDescription = stringResource(id = R.string.navigation_drawer_open), + modifier = Modifier + .size(64.dp) + .clickable(onClick = onNavIconPressed) + .padding(16.dp), + ) + }, + ) } +@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable fun JetchatAppBarPreview() { @@ -84,6 +69,7 @@ fun JetchatAppBarPreview() { } } +@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable fun JetchatAppBarPreviewDark() { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt index 416cb1ff74..6801dfcc16 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt @@ -16,167 +16,266 @@ package com.example.compose.jetchat.components +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment.Companion.CenterStart import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.compose.jetchat.R import com.example.compose.jetchat.data.colleagueProfile import com.example.compose.jetchat.data.meProfile import com.example.compose.jetchat.theme.JetchatTheme -import com.google.accompanist.insets.statusBarsHeight +import com.example.compose.jetchat.widget.WidgetReceiver @Composable -fun ColumnScope.JetchatDrawer(onProfileClicked: (String) -> Unit, onChatClicked: (String) -> Unit) { - // Use statusBarsHeight() to add a spacer which pushes the drawer content +fun JetchatDrawerContent(onProfileClicked: (String) -> Unit, onChatClicked: (String) -> Unit, selectedMenu: String = "composers") { + // Use windowInsetsTopHeight() to add a spacer which pushes the drawer content // below the status bar (y-axis) - Spacer(Modifier.statusBarsHeight()) - DrawerHeader() - Divider() - DrawerItemHeader("Chats") - ChatItem("composers", true) { onChatClicked("composers") } - ChatItem("droidcon-nyc", false) { onChatClicked("droidcon-nyc") } - DrawerItemHeader("Recent Profiles") - ProfileItem("Ali Conors (you)", meProfile.photo) { onProfileClicked(meProfile.userId) } - ProfileItem("Taylor Brooks", colleagueProfile.photo) { - onProfileClicked(colleagueProfile.userId) + Column { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + DrawerHeader() + DividerItem() + DrawerItemHeader("Chats") + ChatItem("composers", selectedMenu == "composers") { + onChatClicked("composers") + } + ChatItem("droidcon-nyc", selectedMenu == "droidcon-nyc") { + onChatClicked("droidcon-nyc") + } + DividerItem(modifier = Modifier.padding(horizontal = 28.dp)) + DrawerItemHeader("Recent Profiles") + ProfileItem( + "Ali Conors (you)", meProfile.photo, + selectedMenu == meProfile.userId, + ) { + onProfileClicked(meProfile.userId) + } + ProfileItem( + "Taylor Brooks", colleagueProfile.photo, + selectedMenu == colleagueProfile.userId, + ) { + onProfileClicked(colleagueProfile.userId) + } + if (widgetAddingIsSupported(LocalContext.current)) { + DividerItem(modifier = Modifier.padding(horizontal = 28.dp)) + DrawerItemHeader("Settings") + WidgetDiscoverability() + } } } @Composable private fun DrawerHeader() { Row(modifier = Modifier.padding(16.dp), verticalAlignment = CenterVertically) { - Image( - painter = painterResource(id = R.drawable.ic_jetchat), + JetchatIcon( contentDescription = null, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) Image( painter = painterResource(id = R.drawable.jetchat_logo), contentDescription = null, - modifier = Modifier.padding(start = 8.dp) + modifier = Modifier.padding(start = 8.dp), ) } } + @Composable private fun DrawerItemHeader(text: String) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text(text, style = MaterialTheme.typography.caption, modifier = Modifier.padding(16.dp)) + Box( + modifier = Modifier + .heightIn(min = 52.dp) + .padding(horizontal = 28.dp), + contentAlignment = CenterStart, + ) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @Composable private fun ChatItem(text: String, selected: Boolean, onChatClicked: () -> Unit) { val background = if (selected) { - Modifier.background(MaterialTheme.colors.primary.copy(alpha = 0.08f)) + Modifier.background(MaterialTheme.colorScheme.primaryContainer) } else { Modifier } Row( modifier = Modifier - .height(48.dp) + .height(56.dp) .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(horizontal = 12.dp) + .clip(CircleShape) .then(background) - .clip(MaterialTheme.shapes.medium) .clickable(onClick = onChatClicked), - verticalAlignment = CenterVertically + verticalAlignment = CenterVertically, ) { val iconTint = if (selected) { - MaterialTheme.colors.primary + MaterialTheme.colorScheme.primary } else { - MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium) + MaterialTheme.colorScheme.onSurfaceVariant } Icon( painter = painterResource(id = R.drawable.ic_jetchat), tint = iconTint, - modifier = Modifier.padding(8.dp), - contentDescription = null + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp), + contentDescription = null, + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.padding(start = 12.dp), ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text, - style = MaterialTheme.typography.body2, - color = if (selected) MaterialTheme.colors.primary else LocalContentColor.current, - modifier = Modifier.padding(8.dp) - ) - } } } @Composable -private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, onProfileClicked: () -> Unit) { +private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, selected: Boolean = false, onProfileClicked: () -> Unit) { + val background = if (selected) { + Modifier.background(MaterialTheme.colorScheme.primaryContainer) + } else { + Modifier + } Row( modifier = Modifier - .height(48.dp) + .height(56.dp) .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) - .clip(MaterialTheme.shapes.medium) + .padding(horizontal = 12.dp) + .clip(CircleShape) + .then(background) .clickable(onClick = onProfileClicked), - verticalAlignment = CenterVertically + verticalAlignment = CenterVertically, ) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - val widthPaddingModifier = Modifier.padding(8.dp).size(24.dp) - if (profilePic != null) { - Image( - painter = painterResource(id = profilePic), - modifier = widthPaddingModifier.then(Modifier.clip(CircleShape)), - contentScale = ContentScale.Crop, - contentDescription = null - ) - } else { - Spacer(modifier = widthPaddingModifier) - } - Text(text, style = MaterialTheme.typography.body2, modifier = Modifier.padding(8.dp)) + val paddingSizeModifier = Modifier + .padding(start = 16.dp, top = 16.dp, bottom = 16.dp) + .size(24.dp) + if (profilePic != null) { + Image( + painter = painterResource(id = profilePic), + modifier = paddingSizeModifier.then(Modifier.clip(CircleShape)), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + Spacer(modifier = paddingSizeModifier) } + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 12.dp), + ) } } +@Composable +fun DividerItem(modifier: Modifier = Modifier) { + HorizontalDivider( + modifier = modifier, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) +} + @Composable @Preview fun DrawerPreview() { JetchatTheme { Surface { Column { - JetchatDrawer({}, {}) + JetchatDrawerContent({}, {}) } } } } + @Composable @Preview fun DrawerPreviewDark() { JetchatTheme(isDarkTheme = true) { Surface { Column { - JetchatDrawer({}, {}) + JetchatDrawerContent({}, {}) } } } } + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun WidgetDiscoverability() { + val context = LocalContext.current + Row( + modifier = Modifier + .height(56.dp) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(CircleShape) + .clickable(onClick = { + addWidgetToHomeScreen(context) + }), + verticalAlignment = CenterVertically, + ) { + Text( + stringResource(id = R.string.add_widget_to_home_page), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 12.dp), + ) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun addWidgetToHomeScreen(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val myProvider = ComponentName(context, WidgetReceiver::class.java) + if (widgetAddingIsSupported(context)) { + appWidgetManager.requestPinAppWidget(myProvider, null, null) + } +} + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) +private fun widgetAddingIsSupported(context: Context): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + AppWidgetManager.getInstance(context).isRequestPinAppWidgetSupported +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatIcon.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatIcon.kt new file mode 100644 index 0000000000..4e8efc3c33 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatIcon.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import com.example.compose.jetchat.R + +@Composable +fun JetchatIcon(contentDescription: String?, modifier: Modifier = Modifier) { + val semantics = if (contentDescription != null) { + Modifier.semantics { + this.contentDescription = contentDescription + this.role = Role.Image + } + } else { + Modifier + } + Box(modifier = modifier.then(semantics)) { + Icon( + painter = painterResource(id = R.drawable.ic_jetchat_back), + contentDescription = null, + tint = MaterialTheme.colorScheme.primaryContainer, + ) + Icon( + painter = painterResource(id = R.drawable.ic_jetchat_front), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt index c11f854090..2f19fb88bf 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt @@ -16,30 +16,40 @@ package com.example.compose.jetchat.components -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.material.Scaffold -import androidx.compose.material.ScaffoldState -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue.Closed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import com.example.compose.jetchat.theme.JetchatTheme @Composable -fun JetchatScaffold( - scaffoldState: ScaffoldState = rememberScaffoldState(), +fun JetchatDrawer( + drawerState: DrawerState = rememberDrawerState(initialValue = Closed), + selectedMenu: String, onProfileClicked: (String) -> Unit, onChatClicked: (String) -> Unit, - content: @Composable (PaddingValues) -> Unit + content: @Composable () -> Unit, ) { JetchatTheme { - Scaffold( - scaffoldState = scaffoldState, + ModalNavigationDrawer( + drawerState = drawerState, drawerContent = { - JetchatDrawer( - onProfileClicked = onProfileClicked, - onChatClicked = onChatClicked - ) + ModalDrawerSheet( + drawerState = drawerState, + drawerContainerColor = MaterialTheme.colorScheme.background, + drawerContentColor = MaterialTheme.colorScheme.onBackground, + ) { + JetchatDrawerContent( + onProfileClicked = onProfileClicked, + onChatClicked = onChatClicked, + selectedMenu = selectedMenu, + ) + } }, - content = content + content = content, ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt deleted file mode 100644 index abef1d10c0..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.conversation - -import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocal -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.staticCompositionLocalOf - -/** - * This [Composable] can be used with a [LocalBackPressedDispatcher] to intercept a back press. - * - * @param onBackPressed (Event) What to do when back is intercepted - * - */ -@Composable -fun BackPressHandler(onBackPressed: () -> Unit) { - // Safely update the current `onBack` lambda when a new one is provided - val currentOnBackPressed by rememberUpdatedState(onBackPressed) - - // Remember in Composition a back callback that calls the `onBackPressed` lambda - val backCallback = remember { - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - currentOnBackPressed() - } - } - } - - val backDispatcher = LocalBackPressedDispatcher.current - - // Whenever there's a new dispatcher set up the callback - DisposableEffect(backDispatcher) { - backDispatcher.addCallback(backCallback) - // When the effect leaves the Composition, or there's a new dispatcher, remove the callback - onDispose { - backCallback.remove() - } - } -} - -/** - * This [CompositionLocal] is used to provide an [OnBackPressedDispatcher]: - * - * ``` - * CompositionLocalProvider( - * LocalBackPressedDispatcher provides requireActivity().onBackPressedDispatcher - * ) { } - * ``` - * - * and setting up the callbacks with [BackPressHandler]. - */ -val LocalBackPressedDispatcher = - staticCompositionLocalOf { error("No Back Dispatcher provided") } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 95a38e0247..14a48d1680 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -14,19 +14,30 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.example.compose.jetchat.conversation +import android.content.ClipDescription +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.paddingFrom import androidx.compose.foundation.layout.size @@ -37,27 +48,34 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.mimeTypes +import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.platform.LocalDensity @@ -68,15 +86,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R import com.example.compose.jetchat.components.JetchatAppBar import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme -import com.example.compose.jetchat.theme.elevatedSurface -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.navigationBarsWithImePadding -import com.google.accompanist.insets.statusBarsPadding -import com.google.accompanist.insets.toPaddingValues import kotlinx.coroutines.launch /** @@ -87,118 +101,183 @@ import kotlinx.coroutines.launch * @param modifier [Modifier] to apply to this layout node * @param onNavIconPressed Sends an event up when the user clicks on the menu */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ConversationContent( uiState: ConversationUiState, navigateToProfile: (String) -> Unit, modifier: Modifier = Modifier, - onNavIconPressed: () -> Unit = { } + onNavIconPressed: () -> Unit = { }, ) { val authorMe = stringResource(R.string.author_me) val timeNow = stringResource(id = R.string.now) val scrollState = rememberLazyListState() + val topBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState) val scope = rememberCoroutineScope() - Surface(modifier = modifier) { - Box(modifier = Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize()) { - Messages( - messages = uiState.messages, - navigateToProfile = navigateToProfile, - modifier = Modifier.weight(1f), - scrollState = scrollState - ) - UserInput( - onMessageSent = { content -> - uiState.addMessage( - Message(authorMe, content, timeNow) - ) - }, - resetScroll = { - scope.launch { - scrollState.scrollToItem(0) - } - }, - // Use navigationBarsWithImePadding(), to move the input panel above both the - // navigation bar, and on-screen keyboard (IME) - modifier = Modifier.navigationBarsWithImePadding(), + + var background by remember { + mutableStateOf(Color.Transparent) + } + + var borderStroke by remember { + mutableStateOf(Color.Transparent) + } + + val dragAndDropCallback = remember { + object : DragAndDropTarget { + override fun onDrop(event: DragAndDropEvent): Boolean { + val clipData = event.toAndroidDragEvent().clipData + + if (clipData.itemCount < 1) { + return false + } + + uiState.addMessage( + Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow), ) + + return true } - // Channel name bar floats above the messages + + override fun onStarted(event: DragAndDropEvent) { + super.onStarted(event) + borderStroke = Color.Red + } + + override fun onEntered(event: DragAndDropEvent) { + super.onEntered(event) + background = Color.Red.copy(alpha = .3f) + } + + override fun onExited(event: DragAndDropEvent) { + super.onExited(event) + background = Color.Transparent + } + + override fun onEnded(event: DragAndDropEvent) { + super.onEnded(event) + background = Color.Transparent + borderStroke = Color.Transparent + } + } + } + + Scaffold( + topBar = { ChannelNameBar( channelName = uiState.channelName, channelMembers = uiState.channelMembers, onNavIconPressed = onNavIconPressed, - // Use statusBarsPadding() to move the app bar content below the status bar - modifier = Modifier.statusBarsPadding(), + scrollBehavior = scrollBehavior, + ) + }, + // Exclude ime and navigation bar padding so this can be added by the UserInput composable + contentWindowInsets = ScaffoldDefaults + .contentWindowInsets + .exclude(WindowInsets.navigationBars) + .exclude(WindowInsets.ime), + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { paddingValues -> + Column( + Modifier.fillMaxSize().padding(paddingValues) + .background(color = background) + .border(width = 2.dp, color = borderStroke) + .dragAndDropTarget(shouldStartDragAndDrop = { event -> + event + .mimeTypes() + .contains( + ClipDescription.MIMETYPE_TEXT_PLAIN, + ) + }, target = dragAndDropCallback), + ) { + Messages( + messages = uiState.messages, + navigateToProfile = navigateToProfile, + modifier = Modifier.weight(1f), + scrollState = scrollState, + ) + UserInput( + onMessageSent = { content -> + uiState.addMessage( + Message(authorMe, content, timeNow), + ) + }, + resetScroll = { + scope.launch { + scrollState.scrollToItem(0) + } + }, + // let this element handle the padding so that the elevation is shown behind the + // navigation bar + modifier = Modifier.navigationBarsPadding().imePadding(), ) } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChannelNameBar( channelName: String, channelMembers: Int, modifier: Modifier = Modifier, - onNavIconPressed: () -> Unit = { } + scrollBehavior: TopAppBarScrollBehavior? = null, + onNavIconPressed: () -> Unit = { }, ) { + var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } + if (functionalityNotAvailablePopupShown) { + FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } + } JetchatAppBar( modifier = modifier, + scrollBehavior = scrollBehavior, onNavIconPressed = onNavIconPressed, title = { - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { // Channel name Text( text = channelName, - style = MaterialTheme.typography.subtitle1 + style = MaterialTheme.typography.titleMedium, ) // Number of members - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(R.string.members, channelMembers), - style = MaterialTheme.typography.caption - ) - } + Text( + text = stringResource(R.string.members, channelMembers), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } }, actions = { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - // Search icon - Icon( - imageVector = Icons.Outlined.Search, - modifier = Modifier - .clickable(onClick = {}) // TODO: Show not implemented dialog. - .padding(horizontal = 12.dp, vertical = 16.dp) - .height(24.dp), - contentDescription = stringResource(id = R.string.search) - ) - // Info icon - Icon( - imageVector = Icons.Outlined.Info, - modifier = Modifier - .clickable(onClick = {}) // TODO: Show not implemented dialog. - .padding(horizontal = 12.dp, vertical = 16.dp) - .height(24.dp), - contentDescription = stringResource(id = R.string.info) - ) - } - } + // Search icon + Icon( + painterResource(id = R.drawable.ic_search), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clickable(onClick = { functionalityNotAvailablePopupShown = true }) + .padding(horizontal = 12.dp, vertical = 16.dp) + .height(24.dp), + contentDescription = stringResource(id = R.string.search), + ) + // Info icon + Icon( + painterResource(id = R.drawable.ic_info), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clickable(onClick = { functionalityNotAvailablePopupShown = true }) + .padding(horizontal = 12.dp, vertical = 16.dp) + .height(24.dp), + contentDescription = stringResource(id = R.string.info), + ) + }, ) } const val ConversationTestTag = "ConversationTestTag" @Composable -fun Messages( - messages: List, - navigateToProfile: (String) -> Unit, - scrollState: LazyListState, - modifier: Modifier = Modifier -) { +fun Messages(messages: List, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) { val scope = rememberCoroutineScope() Box(modifier = modifier) { @@ -206,15 +285,9 @@ fun Messages( LazyColumn( reverseLayout = true, state = scrollState, - // Add content padding so that the content can be scrolled (y-axis) - // below the status bar + app bar - // TODO: Get height from somewhere - contentPadding = LocalWindowInsets.current.statusBars.toPaddingValues( - additionalTop = 90.dp - ), modifier = Modifier .testTag(ConversationTestTag) - .fillMaxSize() + .fillMaxSize(), ) { for (index in messages.indices) { val prevAuthor = messages.getOrNull(index - 1)?.author @@ -236,11 +309,11 @@ fun Messages( item { Message( - onAuthorClick = { navigateToProfile(content.author) }, + onAuthorClick = { name -> navigateToProfile(name) }, msg = content, isUserMe = content.author == authorMe, isFirstMessageByAuthor = isFirstMessageByAuthor, - isLastMessageByAuthor = isLastMessageByAuthor + isLastMessageByAuthor = isLastMessageByAuthor, ) } } @@ -265,34 +338,26 @@ fun Messages( enabled = jumpToBottomButtonEnabled, onClicked = { scope.launch { - // TODO: Replace with animateScrollToItem - // https://issuetracker.google.com/181316785 - scrollState.scrollToItem(0) + scrollState.animateScrollToItem(0) } }, - modifier = Modifier.align(Alignment.BottomCenter) + modifier = Modifier.align(Alignment.BottomCenter), ) } } @Composable fun Message( - onAuthorClick: () -> Unit, + onAuthorClick: (String) -> Unit, msg: Message, isUserMe: Boolean, isFirstMessageByAuthor: Boolean, - isLastMessageByAuthor: Boolean + isLastMessageByAuthor: Boolean, ) { - // TODO: get image from msg.author - val painter = if (isUserMe) { - painterResource(id = R.drawable.ali) - } else { - painterResource(id = R.drawable.someone_else) - } val borderColor = if (isUserMe) { - MaterialTheme.colors.primary + MaterialTheme.colorScheme.primary } else { - MaterialTheme.colors.secondary + MaterialTheme.colorScheme.tertiary } val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier @@ -301,14 +366,14 @@ fun Message( // Avatar Image( modifier = Modifier - .clickable(onClick = onAuthorClick) + .clickable(onClick = { onAuthorClick(msg.author) }) .padding(horizontal = 16.dp) .size(42.dp) .border(1.5.dp, borderColor, CircleShape) - .border(3.dp, MaterialTheme.colors.surface, CircleShape) + .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) .clip(CircleShape) .align(Alignment.Top), - painter = painter, + painter = painterResource(id = msg.authorImage), contentScale = ContentScale.Crop, contentDescription = null, ) @@ -318,11 +383,13 @@ fun Message( } AuthorAndTextMessage( msg = msg, + isUserMe = isUserMe, isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, + authorClicked = onAuthorClick, modifier = Modifier .padding(end = 16.dp) - .weight(1f) + .weight(1f), ) } } @@ -330,15 +397,17 @@ fun Message( @Composable fun AuthorAndTextMessage( msg: Message, + isUserMe: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, - modifier: Modifier = Modifier + authorClicked: (String) -> Unit, + modifier: Modifier = Modifier, ) { Column(modifier = modifier) { if (isLastMessageByAuthor) { AuthorNameTimestamp(msg) } - ChatItemBubble(msg, isFirstMessageByAuthor) + ChatItemBubble(msg, isUserMe, authorClicked = authorClicked) if (isFirstMessageByAuthor) { // Last bubble before next author Spacer(modifier = Modifier.height(8.dp)) @@ -355,83 +424,83 @@ private fun AuthorNameTimestamp(msg: Message) { Row(modifier = Modifier.semantics(mergeDescendants = true) {}) { Text( text = msg.author, - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, modifier = Modifier .alignBy(LastBaseline) - .paddingFrom(LastBaseline, after = 8.dp) // Space to 1st bubble + .paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble ) Spacer(modifier = Modifier.width(8.dp)) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = msg.timestamp, - style = MaterialTheme.typography.caption, - modifier = Modifier.alignBy(LastBaseline) - ) - } + Text( + text = msg.timestamp, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alignBy(LastBaseline), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } -private val ChatBubbleShape = RoundedCornerShape(0.dp, 8.dp, 8.dp, 0.dp) -private val LastChatBubbleShape = RoundedCornerShape(0.dp, 8.dp, 8.dp, 8.dp) +private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp) @Composable fun DayHeader(dayString: String) { Row( modifier = Modifier .padding(vertical = 8.dp, horizontal = 16.dp) - .height(16.dp) + .height(16.dp), ) { DayHeaderLine() - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = dayString, - modifier = Modifier.padding(horizontal = 16.dp), - style = MaterialTheme.typography.overline - ) - } + Text( + text = dayString, + modifier = Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) DayHeaderLine() } } @Composable private fun RowScope.DayHeaderLine() { - Divider( + HorizontalDivider( modifier = Modifier .weight(1f) .align(Alignment.CenterVertically), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), ) } @Composable -fun ChatItemBubble( - message: Message, - lastMessageByAuthor: Boolean -) { +fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { - val backgroundBubbleColor = - if (MaterialTheme.colors.isLight) { - Color(0xFFF5F5F5) - } else { - MaterialTheme.colors.elevatedSurface(2.dp) - } + val backgroundBubbleColor = if (isUserMe) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } - val bubbleShape = if (lastMessageByAuthor) LastChatBubbleShape else ChatBubbleShape Column { - Surface(color = backgroundBubbleColor, shape = bubbleShape) { + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape, + ) { ClickableMessage( - message = message + message = message, + isUserMe = isUserMe, + authorClicked = authorClicked, ) } message.image?.let { Spacer(modifier = Modifier.height(4.dp)) - Surface(color = backgroundBubbleColor, shape = bubbleShape) { + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape, + ) { Image( painter = painterResource(it), contentScale = ContentScale.Fit, modifier = Modifier.size(160.dp), - contentDescription = stringResource(id = R.string.attached_image) + contentDescription = stringResource(id = R.string.attached_image), ) } } @@ -439,15 +508,18 @@ fun ChatItemBubble( } @Composable -fun ClickableMessage(message: Message) { +fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { val uriHandler = LocalUriHandler.current - val styledMessage = messageFormatter(text = message.content) + val styledMessage = messageFormatter( + text = message.content, + primary = isUserMe, + ) ClickableText( text = styledMessage, - style = MaterialTheme.typography.body1.copy(color = LocalContentColor.current), - modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), + modifier = Modifier.padding(16.dp), onClick = { styledMessage .getStringAnnotations(start = it, end = it) @@ -455,12 +527,11 @@ fun ClickableMessage(message: Message) { ?.let { annotation -> when (annotation.tag) { SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item) - // TODO(yrezgui): Open profile screen when click PERSON tag - // (e.g. @aliconors) + SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item) else -> Unit } } - } + }, ) } @@ -470,14 +541,14 @@ fun ConversationPreview() { JetchatTheme { ConversationContent( uiState = exampleUiState, - navigateToProfile = { } + navigateToProfile = { }, ) } } @Preview @Composable -fun channelBarPrev() { +fun ChannelBarPrev() { JetchatTheme { ChannelNameBar(channelName = "composers", channelMembers = 52) } @@ -490,5 +561,3 @@ fun DayHeaderPrev() { } private val JumpToBottomThreshold = 56.dp - -private fun ScrollState.atBottom(): Boolean = value == 0 diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt index 944d523f17..dfb1ffff9f 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt @@ -22,8 +22,6 @@ import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.core.os.bundleOf import androidx.fragment.app.Fragment @@ -33,37 +31,16 @@ import com.example.compose.jetchat.MainViewModel import com.example.compose.jetchat.R import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme -import com.google.accompanist.insets.ExperimentalAnimatedInsets -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.ViewWindowInsetObserver -import com.google.accompanist.insets.navigationBarsPadding class ConversationFragment : Fragment() { private val activityViewModel: MainViewModel by activityViewModels() - @OptIn(ExperimentalAnimatedInsets::class) // Opt-in to experiment animated insets support - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = ComposeView(inflater.context).apply { - layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + ComposeView(inflater.context).apply { + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) - // Create a ViewWindowInsetObserver using this view, and call start() to - // start listening now. The WindowInsets instance is returned, allowing us to - // provide it to AmbientWindowInsets in our content below. - val windowInsets = ViewWindowInsetObserver(this) - // We use the `windowInsetsAnimationsEnabled` parameter to enable animated - // insets support. This allows our `ConversationContent` to animate with the - // on-screen keyboard (IME) as it enters/exits the screen. - .start(windowInsetsAnimationsEnabled = true) - - setContent { - CompositionLocalProvider( - LocalBackPressedDispatcher provides requireActivity().onBackPressedDispatcher, - LocalWindowInsets provides windowInsets, - ) { + setContent { JetchatTheme { ConversationContent( uiState = exampleUiState, @@ -72,18 +49,14 @@ class ConversationFragment : Fragment() { val bundle = bundleOf("userId" to user) findNavController().navigate( R.id.nav_profile, - bundle + bundle, ) }, onNavIconPressed = { activityViewModel.openDrawer() }, - // Add padding so that we are inset from any left/right navigation bars - // (usually shown when in landscape orientation) - modifier = Modifier.navigationBarsPadding(bottom = false) ) } } } - } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt index 3d9baed149..b2ac479e95 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt @@ -17,19 +17,15 @@ package com.example.compose.jetchat.conversation import androidx.compose.runtime.Immutable -import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.toMutableStateList +import com.example.compose.jetchat.R -class ConversationUiState( - val channelName: String, - val channelMembers: Int, - initialMessages: List -) { - private val _messages: MutableList = - mutableStateListOf(*initialMessages.toTypedArray()) +class ConversationUiState(val channelName: String, val channelMembers: Int, initialMessages: List) { + private val _messages: MutableList = initialMessages.toMutableStateList() val messages: List = _messages fun addMessage(msg: Message) { - _messages.add(msg) + _messages.add(0, msg) // Add to the beginning of the list } } @@ -38,5 +34,6 @@ data class Message( val author: String, val content: String, val timestamp: String, - val image: Int? = null + val image: Int? = null, + val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else, ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt index 252f6e24bf..fd0270ae0f 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt @@ -20,15 +20,14 @@ import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset -import androidx.compose.material.ExtendedFloatingActionButton -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -36,21 +35,20 @@ import com.example.compose.jetchat.R private enum class Visibility { VISIBLE, - GONE + GONE, } /** * Shows a button that lets the user scroll to the bottom. */ @Composable -fun JumpToBottom( - enabled: Boolean, - onClicked: () -> Unit, - modifier: Modifier = Modifier -) { +fun JumpToBottom(enabled: Boolean, onClicked: () -> Unit, modifier: Modifier = Modifier) { // Show Jump to Bottom button - val transition = updateTransition(if (enabled) Visibility.VISIBLE else Visibility.GONE) - val bottomOffset by transition.animateDp() { + val transition = updateTransition( + if (enabled) Visibility.VISIBLE else Visibility.GONE, + label = "JumpToBottom visibility animation", + ) + val bottomOffset by transition.animateDp(label = "JumpToBottom offset animation") { if (it == Visibility.GONE) { (-32).dp } else { @@ -61,20 +59,20 @@ fun JumpToBottom( ExtendedFloatingActionButton( icon = { Icon( - imageVector = Icons.Filled.ArrowDownward, + painter = painterResource(id = R.drawable.ic_arrow_downward), modifier = Modifier.height(18.dp), - contentDescription = null + contentDescription = null, ) }, text = { Text(text = stringResource(id = R.string.jumpBottom)) }, onClick = onClicked, - backgroundColor = MaterialTheme.colors.surface, - contentColor = MaterialTheme.colors.primary, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, modifier = modifier .offset(x = 0.dp, y = -bottomOffset) - .height(36.dp) + .height(36.dp), ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt index 433ec6ded3..bcc656edd4 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt @@ -16,8 +16,8 @@ package com.example.compose.jetchat.conversation -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString @@ -37,7 +37,8 @@ val symbolPattern by lazy { // Accepted annotations for the ClickableTextWrapper enum class SymbolAnnotationType { - PERSON, LINK + PERSON, + LINK, } typealias StringAnnotation = AnnotatedString.Range // Pair returning styled content and annotation for ClickableText when matching syntax token @@ -56,9 +57,7 @@ typealias SymbolAnnotation = Pair * @return AnnotatedString with annotations used inside the ClickableText wrapper */ @Composable -fun messageFormatter( - text: String -): AnnotatedString { +fun messageFormatter(text: String, primary: Boolean): AnnotatedString { val tokens = symbolPattern.findAll(text) return buildAnnotatedString { @@ -66,10 +65,10 @@ fun messageFormatter( var cursorPosition = 0 val codeSnippetBackground = - if (MaterialTheme.colors.isLight) { - Color(0xFFDEDEDE) + if (primary) { + MaterialTheme.colorScheme.secondary } else { - Color(0xFF424242) + MaterialTheme.colorScheme.surface } for (token in tokens) { @@ -77,8 +76,9 @@ fun messageFormatter( val (annotatedString, stringAnnotation) = getSymbolAnnotation( matchResult = token, - colors = MaterialTheme.colors, - codeSnippetBackground = codeSnippetBackground + colorScheme = MaterialTheme.colorScheme, + primary = primary, + codeSnippetBackground = codeSnippetBackground, ) append(annotatedString) @@ -106,46 +106,51 @@ fun messageFormatter( */ private fun getSymbolAnnotation( matchResult: MatchResult, - colors: Colors, - codeSnippetBackground: Color + colorScheme: ColorScheme, + primary: Boolean, + codeSnippetBackground: Color, ): SymbolAnnotation { return when (matchResult.value.first()) { '@' -> SymbolAnnotation( AnnotatedString( text = matchResult.value, spanStyle = SpanStyle( - color = colors.primary, - fontWeight = FontWeight.Bold - ) + color = if (primary) colorScheme.inversePrimary else colorScheme.primary, + fontWeight = FontWeight.Bold, + ), ), StringAnnotation( item = matchResult.value.substring(1), start = matchResult.range.first, end = matchResult.range.last, - tag = SymbolAnnotationType.PERSON.name - ) + tag = SymbolAnnotationType.PERSON.name, + ), ) + '*' -> SymbolAnnotation( AnnotatedString( text = matchResult.value.trim('*'), - spanStyle = SpanStyle(fontWeight = FontWeight.Bold) + spanStyle = SpanStyle(fontWeight = FontWeight.Bold), ), - null + null, ) + '_' -> SymbolAnnotation( AnnotatedString( text = matchResult.value.trim('_'), - spanStyle = SpanStyle(fontStyle = FontStyle.Italic) + spanStyle = SpanStyle(fontStyle = FontStyle.Italic), ), - null + null, ) + '~' -> SymbolAnnotation( AnnotatedString( text = matchResult.value.trim('~'), - spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough) + spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough), ), - null + null, ) + '`' -> SymbolAnnotation( AnnotatedString( text = matchResult.value.trim('`'), @@ -153,25 +158,27 @@ private fun getSymbolAnnotation( fontFamily = FontFamily.Monospace, fontSize = 12.sp, background = codeSnippetBackground, - baselineShift = BaselineShift(0.2f) - ) + baselineShift = BaselineShift(0.2f), + ), ), - null + null, ) + 'h' -> SymbolAnnotation( AnnotatedString( text = matchResult.value, spanStyle = SpanStyle( - color = colors.primary - ) + color = if (primary) colorScheme.inversePrimary else colorScheme.primary, + ), ), StringAnnotation( item = matchResult.value, start = matchResult.range.first, end = matchResult.range.last, - tag = SymbolAnnotationType.LINK.name - ) + tag = SymbolAnnotationType.LINK.name, + ), ) + else -> SymbolAnnotation(AnnotatedString(matchResult.value), null) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RecordButton.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RecordButton.kt new file mode 100644 index 0000000000..a1b1c26a18 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RecordButton.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.conversation + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.RichTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipState +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.example.compose.jetchat.R +import kotlin.math.abs +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecordButton( + recording: Boolean, + swipeOffset: () -> Float, + onSwipeOffsetChange: (Float) -> Unit, + onStartRecording: () -> Boolean, + onFinishRecording: () -> Unit, + onCancelRecording: () -> Unit, + modifier: Modifier = Modifier, +) { + val transition = updateTransition(targetState = recording, label = "record") + val scale = transition.animateFloat( + transitionSpec = { spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow) }, + label = "record-scale", + targetValueByState = { rec -> if (rec) 2f else 1f }, + ) + val containerAlpha = transition.animateFloat( + transitionSpec = { tween(2000) }, + label = "record-scale", + targetValueByState = { rec -> if (rec) 1f else 0f }, + ) + val iconColor = transition.animateColor( + transitionSpec = { tween(200) }, + label = "record-scale", + targetValueByState = { rec -> + if (rec) contentColorFor(LocalContentColor.current) + else LocalContentColor.current + }, + ) + + Box { + // Background during recording + Box( + Modifier + .matchParentSize() + .aspectRatio(1f) + .graphicsLayer { + alpha = containerAlpha.value + scaleX = scale.value + scaleY = scale.value + } + .clip(CircleShape) + .background(LocalContentColor.current), + ) + val scope = rememberCoroutineScope() + val tooltipState = remember { TooltipState() } + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + positioning = TooltipAnchorPosition.Above, + ), + tooltip = { + RichTooltip { + Text(stringResource(R.string.touch_and_hold_to_record)) + } + }, + enableUserInput = false, + state = tooltipState, + ) { + Icon( + painterResource(id = R.drawable.ic_mic), + contentDescription = stringResource(R.string.record_message), + tint = iconColor.value, + modifier = modifier + .sizeIn(minWidth = 56.dp, minHeight = 6.dp) + .padding(18.dp) + .clickable { } + .voiceRecordingGesture( + horizontalSwipeProgress = swipeOffset, + onSwipeProgressChanged = onSwipeOffsetChange, + onClick = { scope.launch { tooltipState.show() } }, + onStartRecording = onStartRecording, + onFinishRecording = onFinishRecording, + onCancelRecording = onCancelRecording, + ), + ) + } + } +} + +private fun Modifier.voiceRecordingGesture( + horizontalSwipeProgress: () -> Float, + onSwipeProgressChanged: (Float) -> Unit, + onClick: () -> Unit = {}, + onStartRecording: () -> Boolean = { false }, + onFinishRecording: () -> Unit = {}, + onCancelRecording: () -> Unit = {}, + swipeToCancelThreshold: Dp = 200.dp, + verticalThreshold: Dp = 80.dp, +): Modifier = this + .pointerInput(Unit) { detectTapGestures { onClick() } } + .pointerInput(Unit) { + var offsetY = 0f + var dragging = false + val swipeToCancelThresholdPx = swipeToCancelThreshold.toPx() + val verticalThresholdPx = verticalThreshold.toPx() + + detectDragGesturesAfterLongPress( + onDragStart = { + onSwipeProgressChanged(0f) + offsetY = 0f + dragging = true + onStartRecording() + }, + onDragCancel = { + onCancelRecording() + dragging = false + }, + onDragEnd = { + if (dragging) { + onFinishRecording() + } + dragging = false + }, + onDrag = { change, dragAmount -> + if (dragging) { + onSwipeProgressChanged(horizontalSwipeProgress() + dragAmount.x) + offsetY += dragAmount.y + val offsetX = horizontalSwipeProgress() + if ( + offsetX < 0 && + abs(offsetX) >= swipeToCancelThresholdPx && + abs(offsetY) <= verticalThresholdPx + ) { + onCancelRecording() + dragging = false + } + } + }, + ) + } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt index 98009b8edb..dafccdd3bc 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -16,18 +16,33 @@ package com.example.compose.jetchat.conversation +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -36,30 +51,25 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalContentColor -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AlternateEmail -import androidx.compose.material.icons.outlined.Duo -import androidx.compose.material.icons.outlined.InsertPhoto -import androidx.compose.material.icons.outlined.Mood -import androidx.compose.material.icons.outlined.Place +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -68,15 +78,18 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusState -import androidx.compose.ui.focus.focusModifier import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver @@ -92,8 +105,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R -import com.example.compose.jetchat.theme.compositedOnSurface -import com.example.compose.jetchat.theme.elevatedSurface +import kotlin.math.absoluteValue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay enum class InputSelector { NONE, @@ -101,12 +116,12 @@ enum class InputSelector { DM, EMOJI, PHONE, - PICTURE + PICTURE, } enum class EmojiStickerSelector { EMOJI, - STICKER + STICKER, } @Preview @@ -117,59 +132,65 @@ fun UserInputPreview() { @OptIn(ExperimentalFoundationApi::class) @Composable -fun UserInput( - onMessageSent: (String) -> Unit, - modifier: Modifier = Modifier, - resetScroll: () -> Unit = {}, -) { +fun UserInput(onMessageSent: (String) -> Unit, modifier: Modifier = Modifier, resetScroll: () -> Unit = {}) { var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) } val dismissKeyboard = { currentInputSelector = InputSelector.NONE } // Intercept back navigation if there's a InputSelector visible if (currentInputSelector != InputSelector.NONE) { - BackPressHandler(onBackPressed = dismissKeyboard) + BackHandler(onBack = dismissKeyboard) } - var textState by remember { mutableStateOf(TextFieldValue()) } + var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } // Used to decide if the keyboard should be shown var textFieldFocusState by remember { mutableStateOf(false) } - Column(modifier) { - Divider() - UserInputText( - textFieldValue = textState, - onTextChanged = { textState = it }, - // Only show the keyboard if there's no input selector and text field has focus - keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, - // Close extended selector if text field receives focus - onTextFieldFocused = { focused -> - if (focused) { - currentInputSelector = InputSelector.NONE + Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) { + Column(modifier = modifier) { + UserInputText( + textFieldValue = textState, + onTextChanged = { textState = it }, + // Only show the keyboard if there's no input selector and text field has focus + keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, + // Close extended selector if text field receives focus + onTextFieldFocused = { focused -> + if (focused) { + currentInputSelector = InputSelector.NONE + resetScroll() + } + textFieldFocusState = focused + }, + onMessageSent = { + onMessageSent(textState.text) + // Reset text field and close keyboard + textState = TextFieldValue() + // Move scroll to bottom resetScroll() - } - textFieldFocusState = focused - }, - focusState = textFieldFocusState - ) - UserInputSelector( - onSelectorChange = { currentInputSelector = it }, - sendMessageEnabled = textState.text.isNotBlank(), - onMessageSent = { - onMessageSent(textState.text) - // Reset text field and close keyboard - textState = TextFieldValue() - // Move scroll to bottom - resetScroll() - dismissKeyboard() - }, - currentInputSelector = currentInputSelector - ) - SelectorExpanded( - onCloseRequested = dismissKeyboard, - onTextAdded = { textState = textState.addText(it) }, - currentSelector = currentInputSelector - ) + }, + focusState = textFieldFocusState, + ) + UserInputSelector( + onSelectorChange = { currentInputSelector = it }, + sendMessageEnabled = textState.text.isNotBlank(), + onMessageSent = { + onMessageSent(textState.text) + // Reset text field and close keyboard + textState = TextFieldValue() + // Move scroll to bottom + resetScroll() + dismissKeyboard() + }, + currentInputSelector = currentInputSelector, + ) + SelectorExpanded( + onCloseRequested = dismissKeyboard, + onTextAdded = { textState = textState.addText(it) }, + currentSelector = currentInputSelector, + ) + } } } @@ -177,50 +198,48 @@ private fun TextFieldValue.addText(newString: String): TextFieldValue { val newText = this.text.replaceRange( this.selection.start, this.selection.end, - newString + newString, ) val newSelection = TextRange( start = newText.length, - end = newText.length + end = newText.length, ) return this.copy(text = newText, selection = newSelection) } @Composable -private fun SelectorExpanded( - currentSelector: InputSelector, - onCloseRequested: () -> Unit, - onTextAdded: (String) -> Unit -) { +private fun SelectorExpanded(currentSelector: InputSelector, onCloseRequested: () -> Unit, onTextAdded: (String) -> Unit) { if (currentSelector == InputSelector.NONE) return // Request focus to force the TextField to lose it - val focusRequester = FocusRequester() + val focusRequester = remember { FocusRequester() } // If the selector is shown, always request focus to trigger a TextField.onFocusChange. SideEffect { if (currentSelector == InputSelector.EMOJI) { focusRequester.requestFocus() } } - val selectorExpandedColor = getSelectorExpandedColor() - Surface(color = selectorExpandedColor, elevation = 3.dp) { + Surface(tonalElevation = 8.dp) { when (currentSelector) { InputSelector.EMOJI -> EmojiSelector(onTextAdded, focusRequester) InputSelector.DM -> NotAvailablePopup(onCloseRequested) InputSelector.PICTURE -> FunctionalityNotAvailablePanel() InputSelector.MAP -> FunctionalityNotAvailablePanel() InputSelector.PHONE -> FunctionalityNotAvailablePanel() - else -> { throw NotImplementedError() } + InputSelector.NONE -> Unit } } } -@OptIn(ExperimentalAnimationApi::class) @Composable fun FunctionalityNotAvailablePanel() { - AnimatedVisibility(visible = true, initiallyVisible = false, enter = fadeIn()) { + AnimatedVisibility( + visibleState = remember { MutableTransitionState(false).apply { targetState = true } }, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) { Column( modifier = Modifier .height(320.dp) @@ -230,107 +249,93 @@ fun FunctionalityNotAvailablePanel() { ) { Text( text = stringResource(id = R.string.not_available), - style = MaterialTheme.typography.subtitle1 + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(id = R.string.not_available_subtitle), + modifier = Modifier.paddingFrom(FirstBaseline, before = 32.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.not_available_subtitle), - modifier = Modifier.paddingFrom(FirstBaseline, before = 32.dp), - style = MaterialTheme.typography.body2 - ) - } } } } -@Composable -fun getSelectorExpandedColor(): Color { - return if (MaterialTheme.colors.isLight) { - MaterialTheme.colors.compositedOnSurface(0.04f) - } else { - MaterialTheme.colors.elevatedSurface(8.dp) - } -} - @Composable private fun UserInputSelector( onSelectorChange: (InputSelector) -> Unit, sendMessageEnabled: Boolean, onMessageSent: () -> Unit, currentInputSelector: InputSelector, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Row( modifier = modifier - .height(56.dp) + .height(72.dp) .wrapContentHeight() - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { InputSelectorButton( onClick = { onSelectorChange(InputSelector.EMOJI) }, - icon = Icons.Outlined.Mood, + icon = painterResource(id = R.drawable.ic_mood), selected = currentInputSelector == InputSelector.EMOJI, - description = stringResource(id = R.string.emoji_selector_bt_desc) + description = stringResource(id = R.string.emoji_selector_bt_desc), ) InputSelectorButton( onClick = { onSelectorChange(InputSelector.DM) }, - icon = Icons.Outlined.AlternateEmail, + icon = painterResource(id = R.drawable.ic_alternate_email), selected = currentInputSelector == InputSelector.DM, - description = stringResource(id = R.string.dm_desc) + description = stringResource(id = R.string.dm_desc), ) InputSelectorButton( onClick = { onSelectorChange(InputSelector.PICTURE) }, - icon = Icons.Outlined.InsertPhoto, + icon = painterResource(id = R.drawable.ic_insert_photo), selected = currentInputSelector == InputSelector.PICTURE, - description = stringResource(id = R.string.attach_photo_desc) + description = stringResource(id = R.string.attach_photo_desc), ) InputSelectorButton( onClick = { onSelectorChange(InputSelector.MAP) }, - icon = Icons.Outlined.Place, + icon = painterResource(id = R.drawable.ic_place), selected = currentInputSelector == InputSelector.MAP, - description = stringResource(id = R.string.map_selector_desc) + description = stringResource(id = R.string.map_selector_desc), ) InputSelectorButton( onClick = { onSelectorChange(InputSelector.PHONE) }, - icon = Icons.Outlined.Duo, + icon = painterResource(id = R.drawable.ic_duo), selected = currentInputSelector == InputSelector.PHONE, - description = stringResource(id = R.string.videochat_desc) + description = stringResource(id = R.string.videochat_desc), ) val border = if (!sendMessageEnabled) { BorderStroke( width = 1.dp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), ) } else { null } Spacer(modifier = Modifier.weight(1f)) - val disabledContentColor = - MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) val buttonColors = ButtonDefaults.buttonColors( - disabledBackgroundColor = MaterialTheme.colors.surface, - disabledContentColor = disabledContentColor + disabledContainerColor = Color.Transparent, + disabledContentColor = disabledContentColor, ) // Send button Button( - modifier = Modifier - .padding(horizontal = 16.dp) - .height(36.dp), + modifier = Modifier.height(36.dp), enabled = sendMessageEnabled, onClick = onMessageSent, colors = buttonColors, border = border, - // TODO: Workaround for https://issuetracker.google.com/158830170 - contentPadding = PaddingValues(0.dp) + contentPadding = PaddingValues(0.dp), ) { Text( stringResource(id = R.string.send), - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier.padding(horizontal = 16.dp), ) } } @@ -339,22 +344,36 @@ private fun UserInputSelector( @Composable private fun InputSelectorButton( onClick: () -> Unit, - icon: ImageVector, + icon: androidx.compose.ui.graphics.painter.Painter, description: String, - selected: Boolean + selected: Boolean, + modifier: Modifier = Modifier, ) { - IconButton(onClick = onClick) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - val tint = if (selected) MaterialTheme.colors.primary else LocalContentColor.current - Icon( - icon, - tint = tint, - modifier = Modifier - .padding(12.dp) - .size(20.dp), - contentDescription = description - ) + val backgroundModifier = if (selected) { + Modifier.background( + color = LocalContentColor.current, + shape = RoundedCornerShape(14.dp), + ) + } else { + Modifier + } + IconButton( + onClick = onClick, + modifier = modifier.then(backgroundModifier), + ) { + val tint = if (selected) { + contentColorFor(backgroundColor = LocalContentColor.current) + } else { + LocalContentColor.current } + Icon( + icon, + tint = tint, + modifier = Modifier + .padding(8.dp) + .size(56.dp), + contentDescription = description, + ) } } @@ -366,6 +385,7 @@ private fun NotAvailablePopup(onDismissed: () -> Unit) { val KeyboardShownKey = SemanticsPropertyKey("KeyboardShownKey") var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey +@OptIn(ExperimentalAnimationApi::class) @ExperimentalFoundationApi @Composable private fun UserInputText( @@ -374,70 +394,180 @@ private fun UserInputText( textFieldValue: TextFieldValue, keyboardShown: Boolean, onTextFieldFocused: (Boolean) -> Unit, - focusState: Boolean + onMessageSent: (String) -> Unit, + focusState: Boolean, ) { + val swipeOffset = remember { mutableStateOf(0f) } + var isRecordingMessage by remember { mutableStateOf(false) } val a11ylabel = stringResource(id = R.string.textfield_desc) Row( modifier = Modifier .fillMaxWidth() - .height(48.dp) - .semantics { - contentDescription = a11ylabel - keyboardShownProperty = keyboardShown - }, - horizontalArrangement = Arrangement.End + .height(64.dp), + horizontalArrangement = Arrangement.End, ) { - Surface { - Box( - modifier = Modifier - .height(48.dp) - .weight(1f) - .align(Alignment.Bottom) - ) { - var lastFocusState by remember { mutableStateOf(FocusState.Inactive) } - BasicTextField( - value = textFieldValue, - onValueChange = { onTextChanged(it) }, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp) - .align(Alignment.CenterStart) - .onFocusChanged { state -> - if (lastFocusState != state) { - onTextFieldFocused(state == FocusState.Active) - } - lastFocusState = state + AnimatedContent( + targetState = isRecordingMessage, + label = "text-field", + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) { recording -> + Box(Modifier.fillMaxSize()) { + if (recording) { + RecordingIndicator { swipeOffset.value } + } else { + UserInputTextField( + textFieldValue, + onTextChanged, + onTextFieldFocused, + keyboardType, + focusState, + onMessageSent, + Modifier.fillMaxWidth().semantics { + contentDescription = a11ylabel + keyboardShownProperty = keyboardShown }, - keyboardOptions = KeyboardOptions( - keyboardType = keyboardType, - imeAction = ImeAction.Send - ), - maxLines = 1, - cursorBrush = SolidColor(LocalContentColor.current), - textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current) - ) - - val disableContentColor = - MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) - if (textFieldValue.text.isEmpty() && !focusState) { - Text( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp), - text = stringResource(id = R.string.textfield_hint), - style = MaterialTheme.typography.body1.copy(color = disableContentColor) ) } } } + RecordButton( + recording = isRecordingMessage, + swipeOffset = { swipeOffset.value }, + onSwipeOffsetChange = { offset -> swipeOffset.value = offset }, + onStartRecording = { + val consumed = !isRecordingMessage + isRecordingMessage = true + consumed + }, + onFinishRecording = { + // handle end of recording + isRecordingMessage = false + }, + onCancelRecording = { + isRecordingMessage = false + }, + modifier = Modifier.fillMaxHeight(), + ) } } @Composable -fun EmojiSelector( - onTextAdded: (String) -> Unit, - focusRequester: FocusRequester +private fun BoxScope.UserInputTextField( + textFieldValue: TextFieldValue, + onTextChanged: (TextFieldValue) -> Unit, + onTextFieldFocused: (Boolean) -> Unit, + keyboardType: KeyboardType, + focusState: Boolean, + onMessageSent: (String) -> Unit, + modifier: Modifier = Modifier, ) { + var lastFocusState by remember { mutableStateOf(false) } + BasicTextField( + value = textFieldValue, + onValueChange = { onTextChanged(it) }, + modifier = modifier + .padding(start = 32.dp) + .align(Alignment.CenterStart) + .onFocusChanged { state -> + if (lastFocusState != state.isFocused) { + onTextFieldFocused(state.isFocused) + } + lastFocusState = state.isFocused + }, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Send, + ), + keyboardActions = KeyboardActions { + if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text) + }, + maxLines = 1, + cursorBrush = SolidColor(LocalContentColor.current), + textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current), + ) + + val disableContentColor = + MaterialTheme.colorScheme.onSurfaceVariant + if (textFieldValue.text.isEmpty() && !focusState) { + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 32.dp), + text = stringResource(R.string.textfield_hint), + style = MaterialTheme.typography.bodyLarge.copy(color = disableContentColor), + ) + } +} + +@Composable +private fun RecordingIndicator(swipeOffset: () -> Float) { + var duration by remember { mutableStateOf(Duration.ZERO) } + LaunchedEffect(Unit) { + while (true) { + delay(1000) + duration += 1.seconds + } + } + Row( + Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + + val animatedPulse = infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.2f, + animationSpec = infiniteRepeatable( + tween(2000), + repeatMode = RepeatMode.Reverse, + ), + label = "pulse", + ) + Box( + Modifier + .size(56.dp) + .padding(24.dp) + .graphicsLayer { + scaleX = animatedPulse.value + scaleY = animatedPulse.value + } + .clip(CircleShape) + .background(Color.Red), + ) + Text( + duration.toComponents { minutes, seconds, _ -> + val min = minutes.toString().padStart(2, '0') + val sec = seconds.toString().padStart(2, '0') + "$min:$sec" + }, + Modifier.alignByBaseline(), + ) + Box( + Modifier + .fillMaxSize() + .alignByBaseline() + .clipToBounds(), + ) { + val swipeThreshold = with(LocalDensity.current) { 200.dp.toPx() } + Text( + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { + translationX = swipeOffset() / 2 + alpha = 1 - (swipeOffset().absoluteValue / swipeThreshold) + }, + textAlign = TextAlign.Center, + text = stringResource(R.string.swipe_to_cancel_recording), + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} + +@Composable +fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) { var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) } val a11yLabel = stringResource(id = R.string.emoji_selector_desc) @@ -445,25 +575,25 @@ fun EmojiSelector( modifier = Modifier .focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed // Make the emoji selector focusable so it can steal focus from TextField - .focusModifier() - .semantics { contentDescription = a11yLabel } + .focusTarget() + .semantics { contentDescription = a11yLabel }, ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp) + .padding(horizontal = 8.dp), ) { ExtendedSelectorInnerButton( text = stringResource(id = R.string.emojis_label), onClick = { selected = EmojiStickerSelector.EMOJI }, selected = true, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) ExtendedSelectorInnerButton( text = stringResource(id = R.string.stickers_label), onClick = { selected = EmojiStickerSelector.STICKER }, selected = false, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } Row(modifier = Modifier.verticalScroll(rememberScrollState())) { @@ -476,46 +606,36 @@ fun EmojiSelector( } @Composable -fun ExtendedSelectorInnerButton( - text: String, - onClick: () -> Unit, - selected: Boolean, - modifier: Modifier = Modifier -) { +fun ExtendedSelectorInnerButton(text: String, onClick: () -> Unit, selected: Boolean, modifier: Modifier = Modifier) { val colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.08f), - disabledBackgroundColor = getSelectorExpandedColor(), // Same as background - contentColor = MaterialTheme.colors.onSurface, - disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.74f) + containerColor = if (selected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else Color.Transparent, + disabledContainerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f), ) TextButton( onClick = onClick, modifier = modifier - .padding(horizontal = 8.dp, vertical = 8.dp) - .height(30.dp), - shape = MaterialTheme.shapes.medium, - enabled = selected, + .padding(8.dp) + .height(36.dp), colors = colors, - // TODO: Workaround for https://issuetracker.google.com//158830170 - contentPadding = PaddingValues(0.dp) + contentPadding = PaddingValues(0.dp), ) { Text( text = text, - style = MaterialTheme.typography.subtitle2 + style = MaterialTheme.typography.titleSmall, ) } } @Composable -fun EmojiTable( - onTextAdded: (String) -> Unit, - modifier: Modifier = Modifier -) { +fun EmojiTable(onTextAdded: (String) -> Unit, modifier: Modifier = Modifier) { Column(modifier.fillMaxWidth()) { repeat(4) { x -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + horizontalArrangement = Arrangement.SpaceEvenly, ) { repeat(EMOJI_COLUMNS) { y -> val emoji = emojis[x * EMOJI_COLUMNS + y] @@ -527,8 +647,8 @@ fun EmojiTable( text = emoji, style = LocalTextStyle.current.copy( fontSize = 18.sp, - textAlign = TextAlign.Center - ) + textAlign = TextAlign.Center, + ), ) } } @@ -666,5 +786,5 @@ private val emojis = listOf( "\ud83d\udc6b", // Man and Woman Holding Hands "\ud83d\udc6c", // Two Men Holding Hands "\ud83d\udc6d", // Two Women Holding Hands - "\ud83d\udc8f" // Kiss + "\ud83d\udc8f", // Kiss ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt index fee5f9639c..e019716ad4 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt @@ -19,51 +19,73 @@ package com.example.compose.jetchat.data import com.example.compose.jetchat.R import com.example.compose.jetchat.conversation.ConversationUiState import com.example.compose.jetchat.conversation.Message +import com.example.compose.jetchat.data.EMOJIS.EMOJI_CLOUDS +import com.example.compose.jetchat.data.EMOJIS.EMOJI_FLAMINGO +import com.example.compose.jetchat.data.EMOJIS.EMOJI_MELTING +import com.example.compose.jetchat.data.EMOJIS.EMOJI_PINK_HEART +import com.example.compose.jetchat.data.EMOJIS.EMOJI_POINTS import com.example.compose.jetchat.profile.ProfileScreenState -private val initialMessages = listOf( +val initialMessages = listOf( Message( "me", "Check it out!", - "8:07 PM" + "8:07 PM", ), Message( "me", - "Thank you!", + "Thank you!$EMOJI_PINK_HEART", "8:06 PM", - R.drawable.sticker + R.drawable.sticker, ), Message( "Taylor Brooks", "You can use all the same stuff", - "8:05 PM" + "8:05 PM", ), Message( "Taylor Brooks", - "@aliconors Take a look at the `Flow.collectAsState()` APIs", - "8:05 PM" + "@aliconors Take a look at the `Flow.collectAsStateWithLifecycle()` APIs", + "8:05 PM", ), Message( "John Glenn", - "Compose newbie as well, have you looked at the JetNews sample? Most blog posts end up " + - "out of date pretty fast but this sample is always up to date and deals with async " + - "data loading (it's faked but the same idea applies) \uD83D\uDC49" + - "https://github.com/android/compose-samples/tree/master/JetNews", - "8:04 PM" + "Compose newbie as well $EMOJI_FLAMINGO, have you looked at the JetNews sample? " + + "Most blog posts end up out of date pretty fast but this sample is always up to " + + "date and deals with async data loading (it's faked but the same idea " + + "applies) $EMOJI_POINTS https://goo.gle/jetnews", + "8:04 PM", ), Message( "me", - "Compose newbie: I’ve scourged the internet for tutorials about async data loading " + - "but haven’t found any good ones. What’s the recommended way to load async " + - "data and emit composable widgets?", - "8:03 PM" - ) + "Compose newbie: I’ve scourged the internet for tutorials about async data " + + "loading but haven’t found any good ones $EMOJI_MELTING $EMOJI_CLOUDS. " + + "What’s the recommended way to load async data and emit composable widgets?", + "8:03 PM", + ), + Message( + "Shangeeth Sivan", + "Does anyone know about Glance Widgets its the new way to build widgets in Android!", + "8:08 PM", + ), + Message( + "Taylor Brooks", + "Wow! I never knew about Glance Widgets when was this added to the android ecosystem", + "8:10 PM", + ), + Message( + "John Glenn", + "Yeah its seems to be pretty new!", + "8:12 PM", + ), ) +val unreadMessages = initialMessages.filter { it.author != "me" } + val exampleUiState = ConversationUiState( initialMessages = initialMessages, channelName = "#composers", - channelMembers = 42 + channelMembers = 42, ) /** @@ -78,7 +100,7 @@ val colleagueProfile = ProfileScreenState( position = "Senior Android Dev at Openlane", twitter = "twitter.com/taylorbrookscodes", timeZone = "12:25 AM local time (Eastern Daylight Time)", - commonChannels = "2" + commonChannels = "2", ) /** @@ -93,5 +115,22 @@ val meProfile = ProfileScreenState( position = "Senior Android Dev at Yearin\nGoogle Developer Expert", twitter = "twitter.com/aliconors", timeZone = "In your timezone", - commonChannels = null + commonChannels = null, ) + +object EMOJIS { + // EMOJI 15 + const val EMOJI_PINK_HEART = "\uD83E\uDE77" + + // EMOJI 14 🫠 + const val EMOJI_MELTING = "\uD83E\uDEE0" + + // ANDROID 13.1 😶‍🌫️ + const val EMOJI_CLOUDS = "\uD83D\uDE36\u200D\uD83C\uDF2B️" + + // ANDROID 12.0 🦩 + const val EMOJI_FLAMINGO = "\uD83E\uDDA9" + + // ANDROID 12.0 👉 + const val EMOJI_POINTS = " \uD83D\uDC49" +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt index badefb0c06..94e54a9208 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt @@ -18,7 +18,6 @@ package com.example.compose.jetchat.profile import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -26,93 +25,94 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.FloatingActionButton -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Chat -import androidx.compose.material.icons.outlined.Create -import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R import com.example.compose.jetchat.components.AnimatingFabContent -import com.example.compose.jetchat.components.JetchatAppBar import com.example.compose.jetchat.components.baselineHeight import com.example.compose.jetchat.data.colleagueProfile import com.example.compose.jetchat.data.meProfile import com.example.compose.jetchat.theme.JetchatTheme -import com.google.accompanist.insets.ProvideWindowInsets -import com.google.accompanist.insets.navigationBarsPadding -import com.google.accompanist.insets.statusBarsPadding +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -fun ProfileScreen(userData: ProfileScreenState, onNavIconPressed: () -> Unit = { }) { +fun ProfileScreen( + userData: ProfileScreenState, + nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), +) { + var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } + if (functionalityNotAvailablePopupShown) { + FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } + } val scrollState = rememberScrollState() - Column(modifier = Modifier.fillMaxSize()) { - JetchatAppBar( - // Use statusBarsPadding() to move the app bar content below the status bar - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding(), - onNavIconPressed = onNavIconPressed, - title = { }, - actions = { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - // More icon - Icon( - imageVector = Icons.Outlined.MoreVert, - modifier = Modifier - .clickable(onClick = {}) // TODO: Show not implemented dialog. - .padding(horizontal = 12.dp, vertical = 16.dp) - .height(24.dp), - contentDescription = stringResource(id = R.string.more_options) - ) - } - } - ) - BoxWithConstraints(modifier = Modifier.weight(1f)) { - Surface { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState), - ) { - ProfileHeader( - scrollState, - userData, - this@BoxWithConstraints.maxHeight - ) - UserInfoFields(userData, this@BoxWithConstraints.maxHeight) - } + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollInteropConnection) + .systemBarsPadding(), + ) { + Surface { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) { + ProfileHeader( + scrollState, + userData, + this@BoxWithConstraints.maxHeight, + ) + UserInfoFields(userData, this@BoxWithConstraints.maxHeight) } - ProfileFab( - extended = scrollState.value == 0, - userIsMe = userData.isMe(), - modifier = Modifier.align(Alignment.BottomEnd) - ) } + + val fabExtended by remember { derivedStateOf { scrollState.value == 0 } } + ProfileFab( + extended = fabExtended, + userIsMe = userData.isMe(), + modifier = Modifier + .align(Alignment.BottomEnd) + // Offsets the FAB to compensate for CoordinatorLayout collapsing behaviour + .offset(y = ((-100).dp)), + onFabClicked = { functionalityNotAvailablePopupShown = true }, + ) } } @@ -140,19 +140,17 @@ private fun UserInfoFields(userData: ProfileScreenState, containerHeight: Dp) { } @Composable -private fun NameAndPosition( - userData: ProfileScreenState -) { +private fun NameAndPosition(userData: ProfileScreenState) { Column(modifier = Modifier.padding(horizontal = 16.dp)) { Name( userData, - modifier = Modifier.baselineHeight(32.dp) + modifier = Modifier.baselineHeight(32.dp), ) Position( userData, modifier = Modifier .padding(bottom = 20.dp) - .baselineHeight(24.dp) + .baselineHeight(24.dp), ) } } @@ -162,27 +160,22 @@ private fun Name(userData: ProfileScreenState, modifier: Modifier = Modifier) { Text( text = userData.name, modifier = modifier, - style = MaterialTheme.typography.h5 + style = MaterialTheme.typography.headlineSmall, ) } @Composable private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = userData.position, - modifier = modifier, - style = MaterialTheme.typography.body1 - ) - } + Text( + text = userData.position, + modifier = modifier, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } @Composable -private fun ProfileHeader( - scrollState: ScrollState, - data: ProfileScreenState, - containerHeight: Dp -) { +private fun ProfileHeader(scrollState: ScrollState, data: ProfileScreenState, containerHeight: Dp) { val offset = (scrollState.value / 2) val offsetDp = with(LocalDensity.current) { offset.toDp() } @@ -191,10 +184,16 @@ private fun ProfileHeader( modifier = Modifier .heightIn(max = containerHeight / 2) .fillMaxWidth() - .padding(top = offsetDp), + // TODO: Update to use offset to avoid recomposition + .padding( + start = 16.dp, + top = offsetDp, + end = 16.dp, + ) + .clip(CircleShape), painter = painterResource(id = it), contentScale = ContentScale.Crop, - contentDescription = null + contentDescription = null, ) } } @@ -202,23 +201,22 @@ private fun ProfileHeader( @Composable fun ProfileProperty(label: String, value: String, isLink: Boolean = false) { Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) { - Divider() - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = label, - modifier = Modifier.baselineHeight(24.dp), - style = MaterialTheme.typography.caption - ) - } + HorizontalDivider() + Text( + text = label, + modifier = Modifier.baselineHeight(24.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) val style = if (isLink) { - MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.primary) + MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary) } else { - MaterialTheme.typography.body1 + MaterialTheme.typography.bodyLarge } Text( text = value, modifier = Modifier.baselineHeight(24.dp), - style = style + style = style, ) } } @@ -229,37 +227,35 @@ fun ProfileError() { } @Composable -fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifier) { - - key(userIsMe) { // Prevent multiple invocations to execute during composition +fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifier, onFabClicked: () -> Unit = { }) { + key(userIsMe) { + // Prevent multiple invocations to execute during composition FloatingActionButton( - onClick = { /* TODO */ }, + onClick = onFabClicked, modifier = modifier .padding(16.dp) .navigationBarsPadding() .height(48.dp) .widthIn(min = 48.dp), - backgroundColor = MaterialTheme.colors.primary, - contentColor = MaterialTheme.colors.onPrimary + containerColor = MaterialTheme.colorScheme.tertiaryContainer, ) { AnimatingFabContent( icon = { Icon( - imageVector = if (userIsMe) Icons.Outlined.Create else Icons.Outlined.Chat, + painter = painterResource(id = if (userIsMe) R.drawable.ic_create else R.drawable.ic_chat), contentDescription = stringResource( - if (userIsMe) R.string.edit_profile else R.string.message - ) + if (userIsMe) R.string.edit_profile else R.string.message, + ), ) }, text = { Text( text = stringResource( - id = if (userIsMe) R.string.edit_profile else R.string.message + id = if (userIsMe) R.string.edit_profile else R.string.message, ), ) }, - extended = extended - + extended = extended, ) } } @@ -268,39 +264,31 @@ fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifi @Preview(widthDp = 640, heightDp = 360) @Composable fun ConvPreviewLandscapeMeDefault() { - ProvideWindowInsets(consumeWindowInsets = false) { - JetchatTheme { - ProfileScreen(meProfile) - } + JetchatTheme { + ProfileScreen(meProfile) } } @Preview(widthDp = 360, heightDp = 480) @Composable fun ConvPreviewPortraitMeDefault() { - ProvideWindowInsets(consumeWindowInsets = false) { - JetchatTheme { - ProfileScreen(meProfile) - } + JetchatTheme { + ProfileScreen(meProfile) } } @Preview(widthDp = 360, heightDp = 480) @Composable fun ConvPreviewPortraitOtherDefault() { - ProvideWindowInsets(consumeWindowInsets = false) { - JetchatTheme { - ProfileScreen(colleagueProfile) - } + JetchatTheme { + ProfileScreen(colleagueProfile) } } @Preview @Composable fun ProfileFabPreview() { - ProvideWindowInsets(consumeWindowInsets = false) { - JetchatTheme { - ProfileFab(extended = true, userIsMe = false) - } + JetchatTheme { + ProfileFab(extended = true, userIsMe = false) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt index 4d719d246a..daeaed63db 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt @@ -21,17 +21,33 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.MainViewModel +import com.example.compose.jetchat.R +import com.example.compose.jetchat.components.JetchatAppBar import com.example.compose.jetchat.theme.JetchatTheme -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.ViewWindowInsetObserver class ProfileFragment : Fragment() { @@ -45,38 +61,59 @@ class ProfileFragment : Fragment() { viewModel.setUserId(userId) } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = ComposeView(inflater.context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) + @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false) - // Create a ViewWindowInsetObserver using this view, and call start() to - // start listening now. The WindowInsets instance is returned, allowing us to - // provide it to AmbientWindowInsets in our content below. - val windowInsets = ViewWindowInsetObserver(this).start() + rootView.findViewById(R.id.toolbar_compose_view).apply { + setContent { + var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } + if (functionalityNotAvailablePopupShown) { + FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } + } + + JetchatTheme { + JetchatAppBar( + // Reset the minimum bounds that are passed to the root of a compose tree + modifier = Modifier.wrapContentSize(), + onNavIconPressed = { activityViewModel.openDrawer() }, + title = { }, + actions = { + // More icon + Icon( + painter = painterResource(id = R.drawable.ic_more_vert), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clickable(onClick = { + functionalityNotAvailablePopupShown = true + }) + .padding(horizontal = 12.dp, vertical = 16.dp) + .height(24.dp), + contentDescription = stringResource(id = R.string.more_options), + ) + }, + ) + } + } + } - setContent { - val userData by viewModel.userData.observeAsState() + rootView.findViewById(R.id.profile_compose_view).apply { + setContent { + val userData by viewModel.userData.observeAsState() + val nestedScrollInteropConnection = rememberNestedScrollInteropConnection() - CompositionLocalProvider(LocalWindowInsets provides windowInsets) { JetchatTheme { if (userData == null) { ProfileError() } else { ProfileScreen( userData = userData!!, - onNavIconPressed = { - activityViewModel.openDrawer() - } + nestedScrollInteropConnection = nestedScrollInteropConnection, ) } } } } + return rootView } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt index 792b5db25a..9b2d028094 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt @@ -32,7 +32,12 @@ class ProfileViewModel : ViewModel() { if (newUserId != userId) { userId = newUserId ?: meProfile.userId } - _userData.value = if (userId == meProfile.userId) meProfile else colleagueProfile + // Workaround for simplicity + _userData.value = if (userId == meProfile.userId || userId == meProfile.displayName) { + meProfile + } else { + colleagueProfile + } } private val _userData = MutableLiveData() @@ -42,14 +47,14 @@ class ProfileViewModel : ViewModel() { @Immutable data class ProfileScreenState( val userId: String, - @DrawableRes val photo: Int?, + @param:DrawableRes val photo: Int?, val name: String, val status: String, val displayName: String, val position: String, val twitter: String = "", val timeZone: String?, // Null if me - val commonChannels: String? // Null if me + val commonChannels: String?, // Null if me ) { fun isMe() = userId == meProfile.userId } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt index 8923edc849..35d5181f7d 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,29 +16,45 @@ package com.example.compose.jetchat.theme -import androidx.compose.material.Colors -import androidx.compose.material.LocalElevationOverlay -import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.unit.Dp -/** - * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the - * given [alpha]. Useful for situations where semi-transparent colors are undesirable. - */ -@Composable -fun Colors.compositedOnSurface(alpha: Float): Color { - return onSurface.copy(alpha = alpha).compositeOver(surface) -} +val Blue10 = Color(0xFF000F5E) +val Blue20 = Color(0xFF001E92) +val Blue30 = Color(0xFF002ECC) +val Blue40 = Color(0xFF1546F6) +val Blue80 = Color(0xFFB8C3FF) +val Blue90 = Color(0xFFDDE1FF) -/** - * Calculates the color of an elevated `surface` in dark mode. Returns `surface` in light mode. - */ -@Composable -fun Colors.elevatedSurface(elevation: Dp): Color { - return LocalElevationOverlay.current?.apply( - color = this.surface, - elevation = elevation - ) ?: this.surface -} +val DarkBlue10 = Color(0xFF00036B) +val DarkBlue20 = Color(0xFF000BA6) +val DarkBlue30 = Color(0xFF1026D3) +val DarkBlue40 = Color(0xFF3648EA) +val DarkBlue80 = Color(0xFFBBC2FF) +val DarkBlue90 = Color(0xFFDEE0FF) + +val Yellow10 = Color(0xFF261900) +val Yellow20 = Color(0xFF402D00) +val Yellow30 = Color(0xFF5C4200) +val Yellow40 = Color(0xFF7A5900) +val Yellow80 = Color(0xFFFABD1B) +val Yellow90 = Color(0xFFFFDE9C) + +val Red10 = Color(0xFF410001) +val Red20 = Color(0xFF680003) +val Red30 = Color(0xFF930006) +val Red40 = Color(0xFFBA1B1B) +val Red80 = Color(0xFFFFB4A9) +val Red90 = Color(0xFFFFDAD4) + +val Grey10 = Color(0xFF191C1D) +val Grey20 = Color(0xFF2D3132) +val Grey80 = Color(0xFFC4C7C7) +val Grey90 = Color(0xFFE0E3E3) +val Grey95 = Color(0xFFEFF1F1) +val Grey99 = Color(0xFFFBFDFD) + +val BlueGrey30 = Color(0xFF45464F) +val BlueGrey50 = Color(0xFF767680) +val BlueGrey60 = Color(0xFF90909A) +val BlueGrey80 = Color(0xFFC6C5D0) +val BlueGrey90 = Color(0xFFE2E1EC) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Shapes.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Shapes.kt deleted file mode 100644 index e7b3d589c9..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Shapes.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -val JetchatShapes = Shapes( - small = RoundedCornerShape(50), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(0.dp) -) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt index 074b01cf4f..c323fcd1b5 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt @@ -16,64 +16,97 @@ package com.example.compose.jetchat.theme +import android.annotation.SuppressLint +import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext -private val Yellow400 = Color(0xFFF6E547) -private val Yellow600 = Color(0xFFF5CF1B) -private val Yellow700 = Color(0xFFF3B711) -private val Yellow800 = Color(0xFFF29F05) - -private val Blue200 = Color(0xFF9DA3FA) -private val Blue400 = Color(0xFF4860F7) -private val Blue500 = Color(0xFF0540F2) -private val Blue800 = Color(0xFF001CCF) - -private val Red300 = Color(0xFFEA6D7E) -private val Red800 = Color(0xFFD00036) - -private val JetchatDarkPalette = darkColors( - primary = Blue200, - primaryVariant = Blue400, - onPrimary = Color.Black, - secondary = Yellow400, - onSecondary = Color.Black, - onSurface = Color.White, - onBackground = Color.White, - error = Red300, - onError = Color.Black +val JetchatDarkColorScheme = darkColorScheme( + primary = Blue80, + onPrimary = Blue20, + primaryContainer = Blue30, + onPrimaryContainer = Blue90, + inversePrimary = Blue40, + secondary = DarkBlue80, + onSecondary = DarkBlue20, + secondaryContainer = DarkBlue30, + onSecondaryContainer = DarkBlue90, + tertiary = Yellow80, + onTertiary = Yellow20, + tertiaryContainer = Yellow30, + onTertiaryContainer = Yellow90, + error = Red80, + onError = Red20, + errorContainer = Red30, + onErrorContainer = Red90, + background = Grey10, + onBackground = Grey90, + surface = Grey10, + onSurface = Grey80, + inverseSurface = Grey90, + inverseOnSurface = Grey20, + surfaceVariant = BlueGrey30, + onSurfaceVariant = BlueGrey80, + outline = BlueGrey60, ) -private val JetchatLightPalette = lightColors( - primary = Blue500, - primaryVariant = Blue800, +val JetchatLightColorScheme = lightColorScheme( + primary = Blue40, onPrimary = Color.White, - secondary = Yellow700, - secondaryVariant = Yellow800, - onSecondary = Color.Black, - onSurface = Color.Black, - onBackground = Color.Black, - error = Red800, - onError = Color.White + primaryContainer = Blue90, + onPrimaryContainer = Blue10, + inversePrimary = Blue80, + secondary = DarkBlue40, + onSecondary = Color.White, + secondaryContainer = DarkBlue90, + onSecondaryContainer = DarkBlue10, + tertiary = Yellow40, + onTertiary = Color.White, + tertiaryContainer = Yellow90, + onTertiaryContainer = Yellow10, + error = Red40, + onError = Color.White, + errorContainer = Red90, + onErrorContainer = Red10, + background = Grey99, + onBackground = Grey10, + surface = Grey99, + onSurface = Grey10, + inverseSurface = Grey20, + inverseOnSurface = Grey95, + surfaceVariant = BlueGrey90, + onSurfaceVariant = BlueGrey30, + outline = BlueGrey50, ) +@SuppressLint("NewApi") @Composable -fun JetchatTheme( - isDarkTheme: Boolean = isSystemInDarkTheme(), - colors: Colors? = null, - content: @Composable () -> Unit -) { - val myColors = colors ?: if (isDarkTheme) JetchatDarkPalette else JetchatLightPalette +fun JetchatTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), isDynamicColor: Boolean = true, content: @Composable () -> Unit) { + val dynamicColor = isDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val myColorScheme = when { + dynamicColor && isDarkTheme -> { + dynamicDarkColorScheme(LocalContext.current) + } + + dynamicColor && !isDarkTheme -> { + dynamicLightColorScheme(LocalContext.current) + } + + isDarkTheme -> JetchatDarkColorScheme + + else -> JetchatLightColorScheme + } MaterialTheme( - colors = myColors, - content = content, + colorScheme = myColorScheme, typography = JetchatTypography, - shapes = JetchatShapes + content = content, ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt index e0f215e1f9..2ede128c04 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt @@ -16,94 +16,148 @@ package com.example.compose.jetchat.theme -import androidx.compose.material.Typography +import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont import androidx.compose.ui.unit.sp import com.example.compose.jetchat.R -private val MontserratFontFamily = FontFamily( - Font(R.font.montserrat_regular), - Font(R.font.montserrat_light, FontWeight.Light), - Font(R.font.montserrat_semibold, FontWeight.SemiBold) +val provider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs, ) -private val KarlaFontFamily = FontFamily( - Font(R.font.karla_regular), - Font(R.font.karla_bold, FontWeight.Bold) +val MontserratFont = GoogleFont(name = "Montserrat") + +val KarlaFont = GoogleFont(name = "Karla") + +val MontserratFontFamily = FontFamily( + Font(googleFont = MontserratFont, fontProvider = provider), + Font(resId = R.font.montserrat_regular), + Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Light), + Font(resId = R.font.montserrat_light, weight = FontWeight.Light), + Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Medium), + Font(resId = R.font.montserrat_medium, weight = FontWeight.Medium), + Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.SemiBold), + Font(resId = R.font.montserrat_semibold, weight = FontWeight.SemiBold), +) + +val KarlaFontFamily = FontFamily( + Font(googleFont = KarlaFont, fontProvider = provider), + Font(resId = R.font.karla_regular), + Font(googleFont = KarlaFont, fontProvider = provider, weight = FontWeight.Bold), + Font(resId = R.font.karla_bold, weight = FontWeight.Bold), ) val JetchatTypography = Typography( - defaultFontFamily = MontserratFontFamily, - h1 = TextStyle( + displayLarge = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.Light, - fontSize = 96.sp, - letterSpacing = (-1.5).sp + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = 0.sp, ), - h2 = TextStyle( + displayMedium = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.Light, - fontSize = 60.sp, - letterSpacing = (-0.5).sp + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, ), - h3 = TextStyle( + displaySmall = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.Normal, - fontSize = 48.sp, - letterSpacing = 0.sp + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, ), - h4 = TextStyle( + headlineMedium = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.SemiBold, - fontSize = 30.sp, - letterSpacing = 0.sp + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, ), - h5 = TextStyle( + headlineSmall = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, - letterSpacing = 0.sp + lineHeight = 32.sp, + letterSpacing = 0.sp, ), - h6 = TextStyle( + titleLarge = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.SemiBold, - fontSize = 20.sp, - letterSpacing = 0.sp + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, ), - subtitle1 = TextStyle( + titleMedium = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, - letterSpacing = 0.sp + lineHeight = 24.sp, + letterSpacing = 0.15.sp, ), - subtitle2 = TextStyle( + titleSmall = TextStyle( fontFamily = KarlaFontFamily, fontWeight = FontWeight.Bold, fontSize = 14.sp, - letterSpacing = 0.1.sp + lineHeight = 20.sp, + letterSpacing = 0.1.sp, ), - body1 = TextStyle( + bodyLarge = TextStyle( fontFamily = KarlaFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, - letterSpacing = 0.sp, - lineHeight = 24.sp + lineHeight = 24.sp, + letterSpacing = 0.15.sp, ), - body2 = TextStyle( + bodyMedium = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, - letterSpacing = 0.25.sp - ), - button = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - letterSpacing = 1.25.sp + lineHeight = 20.sp, + letterSpacing = 0.25.sp, ), - caption = TextStyle( + bodySmall = TextStyle( fontFamily = KarlaFontFamily, fontWeight = FontWeight.Bold, fontSize = 12.sp, - letterSpacing = 0.15.sp + lineHeight = 16.sp, + letterSpacing = 0.4.sp, ), - overline = TextStyle( + labelLarge = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, - letterSpacing = 1.sp - ) + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/JetChatWidget.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/JetChatWidget.kt new file mode 100644 index 0000000000..437309351f --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/JetChatWidget.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.provideContent +import com.example.compose.jetchat.data.unreadMessages +import com.example.compose.jetchat.widget.composables.MessagesWidget + +class JetChatWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + MessagesWidget(unreadMessages.toList()) + } + } + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/WidgetReceiver.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/WidgetReceiver.kt new file mode 100644 index 0000000000..ad67c3d170 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/WidgetReceiver.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class WidgetReceiver : GlanceAppWidgetReceiver() { + + override val glanceAppWidget: GlanceAppWidget + get() = JetChatWidget() +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/composables/MessagesWidget.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/composables/MessagesWidget.kt new file mode 100644 index 0000000000..a2b9d00996 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/composables/MessagesWidget.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.widget.composables + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.layout.Column +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.text.Text +import com.example.compose.jetchat.NavActivity +import com.example.compose.jetchat.R +import com.example.compose.jetchat.conversation.Message +import com.example.compose.jetchat.widget.theme.JetChatGlanceTextStyles +import com.example.compose.jetchat.widget.theme.JetchatGlanceColorScheme + +@Composable +fun MessagesWidget(messages: List) { + Scaffold(titleBar = { + TitleBar( + startIcon = ImageProvider(R.drawable.ic_jetchat), + iconColor = null, + title = LocalContext.current.getString(R.string.messages_widget_title), + ) + }, backgroundColor = JetchatGlanceColorScheme.colors.background) { + LazyColumn(modifier = GlanceModifier.fillMaxWidth()) { + messages.forEach { + item { + Column(modifier = GlanceModifier.fillMaxWidth()) { + MessageItem(it) + Spacer(modifier = GlanceModifier.height(10.dp)) + } + } + } + } + } +} + +@Composable +fun MessageItem(message: Message) { + Column(modifier = GlanceModifier.clickable(actionStartActivity()).fillMaxWidth()) { + Text( + text = message.author, + style = JetChatGlanceTextStyles.titleMedium, + ) + Text( + text = message.content, + style = JetChatGlanceTextStyles.bodyMedium, + ) + } +} + +@Preview +@Composable +fun MessageItemPreview() { + MessageItem(Message("John", "This is a preview of the message Item", "8:02PM")) +} + +@Preview +@Composable +fun WidgetPreview() { + MessagesWidget(listOf(Message("John", "This is a preview of the message Item", "8:02PM"))) +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/theme/Theme.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/theme/Theme.kt new file mode 100644 index 0000000000..12a7199a95 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/theme/Theme.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.widget.theme + +import androidx.glance.material3.ColorProviders +import com.example.compose.jetchat.theme.JetchatDarkColorScheme +import com.example.compose.jetchat.theme.JetchatLightColorScheme + +object JetchatGlanceColorScheme { + val colors = ColorProviders( + light = JetchatLightColorScheme, + dark = JetchatDarkColorScheme, + ) +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/theme/Type.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/theme/Type.kt new file mode 100644 index 0000000000..0a60ce8e3e --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/theme/Type.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.widget.theme + +import androidx.compose.ui.unit.sp +import androidx.glance.text.FontWeight +import androidx.glance.text.TextStyle + +object JetChatGlanceTextStyles { + + val titleMedium = TextStyle( + fontSize = 16.sp, + color = JetchatGlanceColorScheme.colors.onSurfaceVariant, + fontWeight = FontWeight.Bold, + ) + val bodyMedium = TextStyle( + fontSize = 16.sp, + color = JetchatGlanceColorScheme.colors.onSurfaceVariant, + fontWeight = FontWeight.Normal, + ) +} diff --git a/Jetchat/app/src/main/res/drawable/ic_alternate_email.xml b/Jetchat/app/src/main/res/drawable/ic_alternate_email.xml new file mode 100644 index 0000000000..37d1268e94 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_alternate_email.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_arrow_downward.xml b/Jetchat/app/src/main/res/drawable/ic_arrow_downward.xml new file mode 100644 index 0000000000..66f1ae3f8e --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_arrow_downward.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_baseline_person_24.xml b/Jetchat/app/src/main/res/drawable/ic_baseline_person_24.xml index dbcd75673d..06248132cb 100644 --- a/Jetchat/app/src/main/res/drawable/ic_baseline_person_24.xml +++ b/Jetchat/app/src/main/res/drawable/ic_baseline_person_24.xml @@ -18,8 +18,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/Jetchat/app/src/main/res/drawable/ic_chat.xml b/Jetchat/app/src/main/res/drawable/ic_chat.xml new file mode 100644 index 0000000000..e53bb33791 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_create.xml b/Jetchat/app/src/main/res/drawable/ic_create.xml new file mode 100644 index 0000000000..db9b31f528 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_create.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_duo.xml b/Jetchat/app/src/main/res/drawable/ic_duo.xml new file mode 100644 index 0000000000..7e0cdcdcde --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_duo.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_info.xml b/Jetchat/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000000..1ece0341d0 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_insert_photo.xml b/Jetchat/app/src/main/res/drawable/ic_insert_photo.xml new file mode 100644 index 0000000000..083151a120 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_insert_photo.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_jetchat_back.xml b/Jetchat/app/src/main/res/drawable/ic_jetchat_back.xml new file mode 100644 index 0000000000..2332fa8a14 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_jetchat_back.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_jetchat_front.xml b/Jetchat/app/src/main/res/drawable/ic_jetchat_front.xml new file mode 100644 index 0000000000..b2200fa4f8 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_jetchat_front.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_launcher_foreground.xml b/Jetchat/app/src/main/res/drawable/ic_launcher_foreground.xml index d5be3e12b8..bdc8735ded 100644 --- a/Jetchat/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/Jetchat/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -16,10 +16,10 @@ + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> diff --git a/Jetchat/app/src/main/res/drawable/ic_launcher_monochrome.xml b/Jetchat/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..5f499a5fc3 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_mic.xml b/Jetchat/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 0000000000..ed523bb689 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/Jetchat/app/src/main/res/drawable/ic_mood.xml b/Jetchat/app/src/main/res/drawable/ic_mood.xml new file mode 100644 index 0000000000..b5f400251c --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_mood.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/Jetchat/app/src/main/res/drawable/ic_more_vert.xml b/Jetchat/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000000..59400ec977 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_place.xml b/Jetchat/app/src/main/res/drawable/ic_place.xml new file mode 100644 index 0000000000..ddb5a94e21 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_place.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_search.xml b/Jetchat/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000000..20c7b4e734 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/Jetchat/app/src/main/res/drawable/widget_icon.png b/Jetchat/app/src/main/res/drawable/widget_icon.png new file mode 100644 index 0000000000..70d386ee16 Binary files /dev/null and b/Jetchat/app/src/main/res/drawable/widget_icon.png differ diff --git a/Jetchat/app/src/main/res/layout/fragment_profile.xml b/Jetchat/app/src/main/res/layout/fragment_profile.xml new file mode 100644 index 0000000000..3cdf5eae3b --- /dev/null +++ b/Jetchat/app/src/main/res/layout/fragment_profile.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/Jetchat/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetchat/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 7353dbd1fd..c78bee3b53 100644 --- a/Jetchat/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/Jetchat/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - - - \ No newline at end of file + + + + diff --git a/Jetchat/app/src/main/res/values-v23/font_certs.xml b/Jetchat/app/src/main/res/values-v23/font_certs.xml new file mode 100644 index 0000000000..207b62f134 --- /dev/null +++ b/Jetchat/app/src/main/res/values-v23/font_certs.xml @@ -0,0 +1,32 @@ + + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + diff --git a/Jetchat/app/src/main/res/values-v23/themes.xml b/Jetchat/app/src/main/res/values-v23/themes.xml index 619594d6f2..0263ad1720 100644 --- a/Jetchat/app/src/main/res/values-v23/themes.xml +++ b/Jetchat/app/src/main/res/values-v23/themes.xml @@ -16,7 +16,7 @@ - diff --git a/Jetchat/app/src/main/res/values-v27/themes.xml b/Jetchat/app/src/main/res/values-v27/themes.xml index 40b0d1402e..a5d1c47d6c 100644 --- a/Jetchat/app/src/main/res/values-v27/themes.xml +++ b/Jetchat/app/src/main/res/values-v27/themes.xml @@ -16,7 +16,7 @@ - diff --git a/Jetchat/app/src/main/res/xml/widget_unread_messages_info.xml b/Jetchat/app/src/main/res/xml/widget_unread_messages_info.xml new file mode 100644 index 0000000000..69ea543dc8 --- /dev/null +++ b/Jetchat/app/src/main/res/xml/widget_unread_messages_info.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/Jetchat/build.gradle b/Jetchat/build.gradle deleted file mode 100644 index ffce75ff36..0000000000 --- a/Jetchat/build.gradle +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -import com.example.compose.jetchat.buildsrc.Libs -import com.example.compose.jetchat.buildsrc.Urls -import com.example.compose.jetchat.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.10.0' -} - -subprojects { - repositories { - google() - mavenCentral() - jcenter() - - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { url Urls.composeSnapshotRepo } - maven { url Urls.accompanistSnapshotRepo } - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - ktlint(Versions.ktlint).userData([android: "true"]) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - - // Enable experimental coroutines APIs, including Flow - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' - freeCompilerArgs += '-Xopt-in=kotlin.Experimental' - - // Set JVM target to 1.8 - jvmTarget = "1.8" - } - } -} diff --git a/Jetchat/build.gradle.kts b/Jetchat/build.gradle.kts new file mode 100644 index 0000000000..e0ceb9e193 --- /dev/null +++ b/Jetchat/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.gradle.versions) + alias(libs.plugins.version.catalog.update) + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.compose) apply false + alias(libs.plugins.spotless) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure { + kotlin { + target("**/*.kt") + targetExclude("${layout.buildDirectory}/**/*.kt") + ktlint() + licenseHeaderFile(rootProject.file("spotless/copyright.kt")) + } + kotlinGradle { + target("*.gradle.kts") + targetExclude("${layout.buildDirectory}/**/*.kt") + ktlint() + // Look for the first line that doesn't have a block comment (assumed to be the license) + licenseHeaderFile(rootProject.file("spotless/copyright.kt"), "(^(?![\\/ ]\\*).*$)") + } + } +} diff --git a/Jetchat/buildSrc/build.gradle.kts b/Jetchat/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Jetchat/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt b/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt deleted file mode 100644 index d43c08a057..0000000000 --- a/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.buildsrc - -object Versions { - const val ktlint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha11" - const val jdkDesugar = "com.android.tools:desugar_jdk_libs:1.0.9" - - const val junit = "junit:junit:4.13" - - const val material = "com.google.android.material:material:1.1.0" - - object Kotlin { - private const val version = "1.4.31" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.2" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object Accompanist { - private const val version = "0.7.0" - const val insets = "com.google.accompanist:accompanist-insets:$version" - } - - object AndroidX { - const val appcompat = "androidx.appcompat:appcompat:1.3.0-beta01" - const val coreKtx = "androidx.core:core-ktx:1.5.0-beta03" - - object Activity { - const val activityCompose = "androidx.activity:activity-compose:1.3.0-alpha05" - } - - object Compose { - const val snapshot = "" - const val version = "1.0.0-beta03" - - const val foundation = "androidx.compose.foundation:foundation:$version" - const val layout = "androidx.compose.foundation:foundation-layout:$version" - const val material = "androidx.compose.material:material:$version" - const val materialIconsExtended = "androidx.compose.material:material-icons-extended:$version" - const val runtime = "androidx.compose.runtime:runtime:$version" - const val runtimeLivedata = "androidx.compose.runtime:runtime-livedata:$version" - const val tooling = "androidx.compose.ui:ui-tooling:$version" - const val test = "androidx.compose.ui:ui-test:$version" - const val uiTest = "androidx.compose.ui:ui-test-junit4:$version" - const val uiUtil = "androidx.compose.ui:ui-util:${version}" - const val viewBinding = "androidx.compose.ui:ui-viewbinding:$version" - } - - object Navigation { - private const val version = "2.3.3" - const val fragment = "androidx.navigation:navigation-fragment-ktx:$version" - const val uiKtx = "androidx.navigation:navigation-ui-ktx:$version" - } - - object Test { - private const val version = "1.3.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.3.0" - } - - object Lifecycle { - private const val version = "2.3.0" - const val extensions = "androidx.lifecycle:lifecycle-extensions:$version" - const val livedata = "androidx.lifecycle:lifecycle-livedata-ktx:$version" - const val viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - const val viewModelCompose = "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha03" - } - } -} - -object Urls { - const val composeSnapshotRepo = "https://androidx.dev/snapshots/builds/" + - "${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" - const val accompanistSnapshotRepo = "https://oss.sonatype.org/content/repositories/snapshots" -} diff --git a/Jetchat/buildscripts/toml-updater-config.gradle b/Jetchat/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/Jetchat/buildscripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/Jetchat/debug.keystore b/Jetchat/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Jetchat/debug.keystore and /dev/null differ diff --git a/Jetchat/debug_2.keystore b/Jetchat/debug_2.keystore new file mode 100644 index 0000000000..b42c971788 Binary files /dev/null and b/Jetchat/debug_2.keystore differ diff --git a/Jetchat/gradle.properties b/Jetchat/gradle.properties index b2d834ce9c..9299bc6d0f 100644 --- a/Jetchat/gradle.properties +++ b/Jetchat/gradle.properties @@ -37,6 +37,3 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Jetchat/gradle/libs.versions.toml b/Jetchat/gradle/libs.versions.toml new file mode 100644 index 0000000000..05d6101203 --- /dev/null +++ b/Jetchat/gradle/libs.versions.toml @@ -0,0 +1,177 @@ +##### +# This file is duplicated to individual samples from the global scripts/libs.versions.toml +# Do not add a dependency to an individual sample, edit the global version instead. +##### +[versions] +accompanist = "0.37.3" +android-material3 = "1.14.0-rc01" +androidGradlePlugin = "9.2.1" +androidx-activity-compose = "1.13.0" +androidx-appcompat = "1.7.1" +androidx-compose-bom = "2026.05.00" +androidx-constraintlayout = "1.1.1" +androidx-core-splashscreen = "1.2.0" +androidx-corektx = "1.18.0" +androidx-glance = "1.1.1" +androidx-lifecycle = "2.8.2" +androidx-lifecycle-compose = "2.10.0" +androidx-lifecycle-runtime-compose = "2.10.0" +androidx-navigation = "2.9.8" +androidx-palette = "1.0.0" +androidx-test = "1.7.0" +androidx-test-espresso = "3.7.0" +androidx-test-ext-junit = "1.3.0" +androidx-test-ext-truth = "1.7.0" +androidx-tv-foundation = "1.0.0" +androidx-tv-material = "1.1.0" +androidx-wear-compose = "1.6.1" +androidx-window = "1.5.1" +androidxHiltNavigationCompose = "1.3.0" +androix-test-uiautomator = "2.3.0" +coil = "2.7.0" +# @keep +compileSdk = "36" +coroutines = "1.11.0" +google-maps = "20.0.0" +gradle-versions = "0.54.0" +hilt = "2.59.2" +hiltExt = "1.3.0" +horologist = "0.7.15" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.3.21" +kotlinx-serialization-json = "1.11.0" +kotlinx_immutable = "0.4.0" +ksp = "2.3.7" +maps-compose = "8.3.0" +# @keep +minSdk = "23" +okhttp = "5.3.2" +play-services-wearable = "20.0.1" +robolectric = "4.16.1" +roborazzi = "1.60.0" +rome = "2.1.0" +room = "2.8.4" +secrets = "2.0.1" +spotless = "8.4.0" +# @keep +targetSdk = "33" +version-catalog-update = "1.1.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } +androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androix-test-uiautomator" } +androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" } +androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" } +androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" } +androidx-wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "androidx-wear-compose" } +androidx-wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "androidx-wear-compose" } +androidx-wear-compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "androidx-wear-compose" } +androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } +androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" } +coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdkDesugar" } +dagger-hiltandroidplugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } +googlemaps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" } +googlemaps-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "google-maps" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltExt" } +horologist-audio-ui = { module = "com.google.android.horologist:horologist-audio-ui", version.ref = "horologist" } +horologist-composables = { module = "com.google.android.horologist:horologist-composables", version.ref = "horologist" } +horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" } +horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologist" } +horologist-compose-tools = { module = "com.google.android.horologist:horologist-compose-tools", version.ref = "horologist" } +horologist-images-coil = { module = "com.google.android.horologist:horologist-images-coil", version.ref = "horologist" } +horologist-media-data = { module = "com.google.android.horologist:horologist-media-data", version.ref = "horologist" } +horologist-media-ui = { module = "com.google.android.horologist:horologist-media-ui", version.ref = "horologist" } +horologist-roboscreenshots = { module = "com.google.android.horologist:horologist-roboscreenshots", version.ref = "horologist" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx_immutable" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" } +roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" } +roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" } +rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome" } +rometools-rome = { module = "com.rometools:rome", version.ref = "rome" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } +android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } +compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } +secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/Jetchat/gradle/wrapper/gradle-wrapper.jar b/Jetchat/gradle/wrapper/gradle-wrapper.jar index e708b1c023..d997cfc60f 100644 Binary files a/Jetchat/gradle/wrapper/gradle-wrapper.jar and b/Jetchat/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Jetchat/gradle/wrapper/gradle-wrapper.properties b/Jetchat/gradle/wrapper/gradle-wrapper.properties index 2a563242c1..c61a118f7d 100644 --- a/Jetchat/gradle/wrapper/gradle-wrapper.properties +++ b/Jetchat/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/Jetchat/gradlew b/Jetchat/gradlew index 4f906e0c81..739907dfd1 100755 --- a/Jetchat/gradlew +++ b/Jetchat/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,114 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/Jetchat/gradlew.bat b/Jetchat/gradlew.bat index ac1b06f938..e509b2dd8f 100644 --- a/Jetchat/gradlew.bat +++ b/Jetchat/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/Jetchat/screenshots/ime-transition.gif b/Jetchat/screenshots/ime-transition.gif deleted file mode 100644 index 7532d97bda..0000000000 Binary files a/Jetchat/screenshots/ime-transition.gif and /dev/null differ diff --git a/Jetchat/screenshots/jetchat.gif b/Jetchat/screenshots/jetchat.gif index d11a349f88..01217ebb57 100644 Binary files a/Jetchat/screenshots/jetchat.gif and b/Jetchat/screenshots/jetchat.gif differ diff --git a/Jetchat/screenshots/screenshots.png b/Jetchat/screenshots/screenshots.png new file mode 100644 index 0000000000..f03282664f Binary files /dev/null and b/Jetchat/screenshots/screenshots.png differ diff --git a/Jetchat/screenshots/widget.png b/Jetchat/screenshots/widget.png new file mode 100644 index 0000000000..7a3cf48e39 Binary files /dev/null and b/Jetchat/screenshots/widget.png differ diff --git a/Jetchat/screenshots/widget_discoverability.png b/Jetchat/screenshots/widget_discoverability.png new file mode 100644 index 0000000000..a59e4d5b09 Binary files /dev/null and b/Jetchat/screenshots/widget_discoverability.png differ diff --git a/Jetchat/settings.gradle b/Jetchat/settings.gradle deleted file mode 100644 index 4c2a42f96d..0000000000 --- a/Jetchat/settings.gradle +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -include ':app' -rootProject.name = "Jetchat" \ No newline at end of file diff --git a/Jetchat/settings.gradle.kts b/Jetchat/settings.gradle.kts new file mode 100644 index 0000000000..7ce9640e5e --- /dev/null +++ b/Jetchat/settings.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +val snapshotVersion : String? = System.getenv("COMPOSE_SNAPSHOT_ID") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + snapshotVersion?.let { + println("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") + maven { url = uri("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") } + } + + google() + mavenCentral() + } +} +rootProject.name = "Jetchat" +include(":app") + diff --git a/Jetsnack/.editorconfig b/Jetsnack/.editorconfig new file mode 100644 index 0000000000..ae57e08c44 --- /dev/null +++ b/Jetsnack/.editorconfig @@ -0,0 +1,26 @@ +# When authoring changes in .editorconfig, run ./gradlew spotlessApply --no-daemon +# Reference: https://github.com/diffplug/spotless/issues/1924 +[*.{kt,kts}] +ktlint_code_style = android_studio +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +max_line_length = 140 # ktlint official +ktlint_function_naming_ignore_when_annotated_with = Composable, Test +ktlint_standard_filename = disabled +ktlint_standard_package-name = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_backing-property-naming = disabled +ktlint_standard_argument-list-wrapping=disabled +ktlint_standard_parameter-list-wrapping = disabled +ktlint_standard_double-colon-spacing=disabled +ktlint_standard_enum-entry-name-case=disabled +ktlint_standard_multiline-if-else=disabled +ktlint_standard_no-empty-first-line-in-method-block = disabled +ktlint_standard_package-name = disabled +ktlint_standard_trailing-comma = disabled +ktlint_standard_spacing-around-angle-brackets = disabled +ktlint_standard_spacing-between-declarations-with-annotations = disabled +ktlint_standard_spacing-between-declarations-with-comments = disabled +ktlint_standard_unary-op-spacing = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_value-parameter-comment = disabled diff --git a/Jetsnack/.gitignore b/Jetsnack/.gitignore index aa724b7707..834ecd9dff 100644 --- a/Jetsnack/.gitignore +++ b/Jetsnack/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +.kotlin/ diff --git a/Jetsnack/.google/packaging.yaml b/Jetsnack/.google/packaging.yaml index 1d49c703c6..92e33aec34 100644 --- a/Jetsnack/.google/packaging.yaml +++ b/Jetsnack/.google/packaging.yaml @@ -18,10 +18,16 @@ # End users may safely ignore this file. It has no relevance to other systems. --- status: PUBLISHED -technologies: [Android] -categories: [Compose] +technologies: [Android, JetpackCompose] +categories: + - JetpackComposeDesignSystems + - JetpackComposeAnimation + - JetpackComposeLayouts languages: [Kotlin] -solutions: [Mobile] +solutions: + - Mobile + - JetpackLifecycle + - JetpackNavigation github: android/compose-samples level: ADVANCED apiRefs: diff --git a/Jetsnack/README.md b/Jetsnack/README.md index dd3f469ac8..d738b3552d 100644 --- a/Jetsnack/README.md +++ b/Jetsnack/README.md @@ -2,7 +2,8 @@ Jetsnack is a sample snack ordering app built with [Jetpack Compose][compose]. -To try out these sample apps, you need to use the latest Canary version of Android Studio 4.2. +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). You can clone this repository or import the project from Android Studio following the steps [here](https://developer.android.com/jetpack/compose/setup#sample). @@ -13,7 +14,9 @@ This sample showcases: * Custom layout * Animation - +## Screenshots + + ### Status: 🚧 In progress 🚧 @@ -50,8 +53,10 @@ Jetsnack utilizes custom [`Layout`](https://developer.android.com/reference/kotl ## Data Domain types are modelled in the [model package](app/src/main/java/com/example/jetsnack/model), each containing static sample data exposed using fake `Repo`s objects. -Imagery is sourced from [Unsplash](https://unsplash.com/) and loaded using [coil-accompanist][coil-accompanist]. +Imagery is sourced from [Unsplash](https://unsplash.com/) and loaded using the [Coil][coil] library. +## Baseline Profiles +For [Baseline profiles](https://developer.android.com/topic/performance/baselineprofiles), see the [compose-latest](https://github.com/android/compose-samples/tree/compose-latest/Jetsnack) branch. ## License @@ -72,4 +77,4 @@ limitations under the License. ``` [compose]: https://developer.android.com/jetpack/compose -[coil-accompanist]: https://google.github.io/accompanist/coil/ +[coil]: https://coil-kt.github.io/coil/ diff --git a/Jetsnack/app/build.gradle b/Jetsnack/app/build.gradle deleted file mode 100644 index cd2ae666ac..0000000000 --- a/Jetsnack/app/build.gradle +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.jetsnack.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' - id 'kotlin-parcelize' -} - -android { - compileSdkVersion 30 - - defaultConfig { - applicationId 'com.example.jetsnack' - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName '1.0' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - buildFeatures { - compose true - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - implementation Libs.Coroutines.core - - implementation Libs.AndroidX.coreKtx - implementation Libs.AndroidX.Activity.activityCompose - implementation Libs.AndroidX.Lifecycle.viewModelCompose - implementation Libs.AndroidX.ConstraintLayout.constraintLayoutCompose - - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.foundation - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.ui - implementation Libs.AndroidX.Compose.uiUtil - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.animation - implementation Libs.AndroidX.Compose.iconsExtended - implementation Libs.AndroidX.Compose.tooling - - implementation Libs.Accompanist.coil - implementation Libs.Accompanist.insets -} diff --git a/Jetsnack/app/build.gradle.kts b/Jetsnack/app/build.gradle.kts new file mode 100644 index 0000000000..0fca21037b --- /dev/null +++ b/Jetsnack/app/build.gradle.kts @@ -0,0 +1,147 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.compose) +} + +android { + compileSdk = + libs.versions.compileSdk + .get() + .toInt() + namespace = "com.example.jetsnack" + + defaultConfig { + applicationId = "com.example.jetsnack" + minSdk = + libs.versions.minSdk + .get() + .toInt() + targetSdk = + libs.versions.targetSdk + .get() + .toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + // Important: change the keystore for a production deployment + val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore") + val localKeystore = rootProject.file("debug_2.keystore") + val hasKeyInfo = userKeystore.exists() + create("release") { + storeFile = if (hasKeyInfo) userKeystore else localKeystore + storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password") + keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias") + keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password") + } + } + + buildTypes { + getByName("debug") { + } + + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("release") + matchingFallbacks.add("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-benchmark-rules.pro", + ) + isDebuggable = false + } + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging.resources { + // Multiple dependency bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.util) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.coil.kt.compose) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.compose.ui.test) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.preview) +} diff --git a/Jetsnack/app/proguard-benchmark-rules.pro b/Jetsnack/app/proguard-benchmark-rules.pro new file mode 100644 index 0000000000..5849b43aae --- /dev/null +++ b/Jetsnack/app/proguard-benchmark-rules.pro @@ -0,0 +1,28 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses + +# When generating the baseline profile we want the proper names of +# the methods and classes +-dontobfuscate \ No newline at end of file diff --git a/Jetsnack/app/proguard-rules.pro b/Jetsnack/app/proguard-rules.pro index 4cb94585a0..f8f76182de 100644 --- a/Jetsnack/app/proguard-rules.pro +++ b/Jetsnack/app/proguard-rules.pro @@ -22,3 +22,16 @@ # Repackage classes into the top-level. -repackageclasses + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE + +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } \ No newline at end of file diff --git a/Jetsnack/app/src/androidTest/java/com/example/jetsnack/AppTest.kt b/Jetsnack/app/src/androidTest/java/com/example/jetsnack/AppTest.kt new file mode 100644 index 0000000000..ba224672f9 --- /dev/null +++ b/Jetsnack/app/src/androidTest/java/com/example/jetsnack/AppTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.example.jetsnack.ui.MainActivity +import org.junit.Rule +import org.junit.Test + +class AppTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun app_launches() { + // Check app launches at the correct destination + composeTestRule.onNodeWithText("HOME").assertIsDisplayed() + composeTestRule.onNodeWithText("Android's picks").assertIsDisplayed() + } + + @Test + fun app_canNavigateToAllScreens() { + // Check app launches at HOME + composeTestRule.onNodeWithText("HOME").assertIsDisplayed() + composeTestRule.onNodeWithText("Android's picks").assertIsDisplayed() + + // Navigate to Search + composeTestRule.onNodeWithText("SEARCH").performClick().assertIsDisplayed() + composeTestRule.onNodeWithText("Categories").assertIsDisplayed() + + // Navigate to Cart + composeTestRule.onNodeWithText("MY CART").performClick().assertIsDisplayed() + composeTestRule.onNodeWithText("Order (3 items)").assertIsDisplayed() + + // Navigate to Profile + composeTestRule.onNodeWithText("PROFILE").performClick().assertIsDisplayed() + composeTestRule.onNodeWithText("This is currently work in progress").assertIsDisplayed() + } + + @Test + fun app_canNavigateToDetailPage() { + composeTestRule.onNodeWithText("Chips").performClick() + composeTestRule.onNodeWithText("Lorem ipsum", substring = true).assertIsDisplayed() + } +} diff --git a/Jetsnack/app/src/main/AndroidManifest.xml b/Jetsnack/app/src/main/AndroidManifest.xml index c98807608e..73c8131195 100644 --- a/Jetsnack/app/src/main/AndroidManifest.xml +++ b/Jetsnack/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - + xmlns:tools="http://schemas.android.com/tools"> @@ -23,16 +22,36 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" - android:theme="@style/Theme.Jetsnack"> + android:theme="@style/Theme.Jetsnack" + android:enableOnBackInvokedCallback="true" + tools:targetApi="33"> + + + + + + + + + + android:exported="true" + android:theme="@style/Theme.Jetsnack" + android:windowSoftInputMode="adjustResize"> + diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Filter.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Filter.kt index 52917f2ef4..d438ff8e54 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Filter.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Filter.kt @@ -16,14 +16,13 @@ package com.example.jetsnack.model +import androidx.annotation.DrawableRes import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf +import com.example.jetsnack.R @Stable -class Filter( - val name: String, - enabled: Boolean = false -) { +class Filter(val name: String, enabled: Boolean = false, @DrawableRes val icon: Int? = null) { val enabled = mutableStateOf(enabled) } @@ -32,5 +31,32 @@ val filters = listOf( Filter(name = "Gluten-free"), Filter(name = "Dairy-free"), Filter(name = "Sweet"), - Filter(name = "Savory") + Filter(name = "Savory"), ) +val priceFilters = listOf( + Filter(name = "$"), + Filter(name = "$$"), + Filter(name = "$$$"), + Filter(name = "$$$$"), +) +val sortFilters = listOf( + Filter(name = "Android's favorite (default)", icon = R.drawable.ic_android), + Filter(name = "Rating", icon = R.drawable.ic_star), + Filter(name = "Alphabetical", icon = R.drawable.ic_sort_by_alpha), +) + +val categoryFilters = listOf( + Filter(name = "Chips & crackers"), + Filter(name = "Fruit snacks"), + Filter(name = "Desserts"), + Filter(name = "Nuts"), +) +val lifeStyleFilters = listOf( + Filter(name = "Organic"), + Filter(name = "Gluten-free"), + Filter(name = "Dairy-free"), + Filter(name = "Sweet"), + Filter(name = "Savory"), +) + +var sortDefault = sortFilters.get(0).name diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Search.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Search.kt index d22a41edb4..c92fffa1dc 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Search.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Search.kt @@ -17,6 +17,7 @@ package com.example.jetsnack.model import androidx.compose.runtime.Immutable +import com.example.jetsnack.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -35,24 +36,13 @@ object SearchRepo { } @Immutable -data class SearchCategoryCollection( - val id: Long, - val name: String, - val categories: List -) +data class SearchCategoryCollection(val id: Long, val name: String, val categories: List) @Immutable -data class SearchCategory( - val name: String, - val imageUrl: String -) +data class SearchCategory(val name: String, val imageRes: Int) @Immutable -data class SearchSuggestionGroup( - val id: Long, - val name: String, - val suggestions: List -) +data class SearchSuggestionGroup(val id: Long, val name: String, val suggestions: List) /** * Static data @@ -65,21 +55,21 @@ private val searchCategoryCollections = listOf( categories = listOf( SearchCategory( name = "Chips & crackers", - imageUrl = "https://source.unsplash.com/UsSdMZ78Q3E" + imageRes = R.drawable.chips, ), SearchCategory( name = "Fruit snacks", - imageUrl = "https://source.unsplash.com/SfP1PtM9Qa8" + imageRes = R.drawable.fruit, ), SearchCategory( name = "Desserts", - imageUrl = "https://source.unsplash.com/_jk8KIyN_uA" + imageRes = R.drawable.desserts, ), SearchCategory( - name = "Nuts ", - imageUrl = "https://source.unsplash.com/UsSdMZ78Q3E" - ) - ) + name = "Nuts", + imageRes = R.drawable.nuts, + ), + ), ), SearchCategoryCollection( id = 1L, @@ -87,30 +77,30 @@ private val searchCategoryCollections = listOf( categories = listOf( SearchCategory( name = "Organic", - imageUrl = "https://source.unsplash.com/7meCnGCJ5Ms" + imageRes = R.drawable.organic, ), SearchCategory( name = "Gluten Free", - imageUrl = "https://source.unsplash.com/m741tj4Cz7M" + imageRes = R.drawable.gluten_free, ), SearchCategory( name = "Paleo", - imageUrl = "https://source.unsplash.com/dt5-8tThZKg" + imageRes = R.drawable.paleo, ), SearchCategory( name = "Vegan", - imageUrl = "https://source.unsplash.com/ReXxkS1m1H0" + imageRes = R.drawable.vegan, ), SearchCategory( - name = "Vegitarian", - imageUrl = "https://source.unsplash.com/IGfIGP5ONV0" + name = "Vegetarian", + imageRes = R.drawable.organic, ), SearchCategory( name = "Whole30", - imageUrl = "https://source.unsplash.com/9MzCd76xLGk" - ) - ) - ) + imageRes = R.drawable.paleo, + ), + ), + ), ) private val searchSuggestions = listOf( @@ -119,8 +109,8 @@ private val searchSuggestions = listOf( name = "Recent searches", suggestions = listOf( "Cheese", - "Apple Sauce" - ) + "Apple Sauce", + ), ), SearchSuggestionGroup( id = 1L, @@ -131,7 +121,7 @@ private val searchSuggestions = listOf( "Paleo", "Vegan", "Vegitarian", - "Whole30" - ) - ) + "Whole30", + ), + ), ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Snack.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Snack.kt index 7ef141543b..27754d4b46 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Snack.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Snack.kt @@ -16,16 +16,20 @@ package com.example.jetsnack.model +import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable +import com.example.jetsnack.R +import kotlin.random.Random @Immutable data class Snack( val id: Long, val name: String, - val imageUrl: String, + @DrawableRes + val imageRes: Int, val price: Long, val tagline: String = "", - val tags: Set = emptySet() + val tags: Set = emptySet(), ) /** @@ -37,190 +41,190 @@ val snacks = listOf( id = 1L, name = "Cupcake", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/pGM4sjt_BdQ", - price = 299 + imageRes = R.drawable.cupcake, + price = 299, ), Snack( - id = 2L, + id = Random.nextLong(), name = "Donut", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/Yc5sL-ejk6U", - price = 299 + imageRes = R.drawable.donut, + price = 299, ), Snack( - id = 3L, + id = Random.nextLong(), name = "Eclair", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/-LojFX9NfPY", - price = 299 + imageRes = R.drawable.eclair, + price = 299, ), Snack( - id = 4L, + id = Random.nextLong(), name = "Froyo", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/3U2V5WqK1PQ", - price = 299 + imageRes = R.drawable.froyo, + price = 299, ), Snack( - id = 5L, + id = Random.nextLong(), name = "Gingerbread", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/Y4YR9OjdIMk", - price = 499 + imageRes = R.drawable.gingerbread, + price = 499, ), Snack( - id = 6L, + id = Random.nextLong(), name = "Honeycomb", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/bELvIg_KZGU", - price = 299 + imageRes = R.drawable.honeycomb, + price = 299, ), Snack( - id = 7L, + id = Random.nextLong(), name = "Ice Cream Sandwich", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/YgYJsFDd4AU", - price = 1299 + imageRes = R.drawable.ice_cream_sandwich, + price = 1299, ), Snack( - id = 8L, + id = Random.nextLong(), name = "Jellybean", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/0u_vbeOkMpk", - price = 299 + imageRes = R.drawable.jelly_bean, + price = 299, ), Snack( - id = 9L, + id = Random.nextLong(), name = "KitKat", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/yb16pT5F_jE", - price = 549 + imageRes = R.drawable.kitkat, + price = 549, ), Snack( - id = 10L, + id = Random.nextLong(), name = "Lollipop", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/AHF_ZktTL6Q", - price = 299 + imageRes = R.drawable.lollipop, + price = 299, ), Snack( - id = 11L, + id = Random.nextLong(), name = "Marshmallow", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/rqFm0IgMVYY", - price = 299 + imageRes = R.drawable.marshmallow, + price = 299, ), Snack( - id = 12L, + id = Random.nextLong(), name = "Nougat", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/qRE_OpbVPR8", - price = 299 + imageRes = R.drawable.nougat, + price = 299, ), Snack( - id = 13L, + id = Random.nextLong(), name = "Oreo", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/33fWPnyN6tU", - price = 299 + imageRes = R.drawable.oreo, + price = 299, ), Snack( - id = 14L, + id = Random.nextLong(), name = "Pie", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/aX_ljOOyWJY", - price = 299 + imageRes = R.drawable.pie, + price = 299, ), Snack( - id = 15L, + id = Random.nextLong(), name = "Chips", - imageUrl = "https://source.unsplash.com/UsSdMZ78Q3E", - price = 299 + imageRes = R.drawable.chips, + price = 299, ), Snack( - id = 16L, + id = Random.nextLong(), name = "Pretzels", - imageUrl = "https://source.unsplash.com/7meCnGCJ5Ms", - price = 299 + imageRes = R.drawable.pretzels, + price = 299, ), Snack( - id = 17L, + id = Random.nextLong(), name = "Smoothies", - imageUrl = "https://source.unsplash.com/m741tj4Cz7M", - price = 299 + imageRes = R.drawable.smoothies, + price = 299, ), Snack( - id = 18L, + id = Random.nextLong(), name = "Popcorn", - imageUrl = "https://source.unsplash.com/iuwMdNq0-s4", - price = 299 + imageRes = R.drawable.popcorn, + price = 299, ), Snack( - id = 19L, + id = Random.nextLong(), name = "Almonds", - imageUrl = "https://source.unsplash.com/qgWWQU1SzqM", - price = 299 + imageRes = R.drawable.almonds, + price = 299, ), Snack( - id = 20L, + id = Random.nextLong(), name = "Cheese", - imageUrl = "https://source.unsplash.com/9MzCd76xLGk", - price = 299 + imageRes = R.drawable.cheese, + price = 299, ), Snack( - id = 21L, + id = Random.nextLong(), name = "Apples", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/1d9xXWMtQzQ", - price = 299 + imageRes = R.drawable.apples, + price = 299, ), Snack( - id = 22L, + id = Random.nextLong(), name = "Apple sauce", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/wZxpOw84QTU", - price = 299 + imageRes = R.drawable.apple_sauce, + price = 299, ), Snack( - id = 23L, + id = Random.nextLong(), name = "Apple chips", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/okzeRxm_GPo", - price = 299 + imageRes = R.drawable.apple_chips, + price = 299, ), Snack( - id = 24L, + id = Random.nextLong(), name = "Apple juice", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/l7imGdupuhU", - price = 299 + imageRes = R.drawable.apple_juice, + price = 299, ), Snack( - id = 25L, + id = Random.nextLong(), name = "Apple pie", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/bkXzABDt08Q", - price = 299 + imageRes = R.drawable.apple_pie, + price = 299, ), Snack( - id = 26L, + id = Random.nextLong(), name = "Grapes", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/y2MeW00BdBo", - price = 299 + imageRes = R.drawable.grapes, + price = 299, ), Snack( - id = 27L, + id = Random.nextLong(), name = "Kiwi", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/1oMGgHn-M8k", - price = 299 + imageRes = R.drawable.kiwi, + price = 299, ), Snack( - id = 28L, + id = Random.nextLong(), name = "Mango", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/TIGDsyy0TK4", - price = 299 - ) + imageRes = R.drawable.mango, + price = 299, + ), ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackCollection.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackCollection.kt index 77da39594b..da4ecd49b3 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackCollection.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackCollection.kt @@ -17,14 +17,10 @@ package com.example.jetsnack.model import androidx.compose.runtime.Immutable +import kotlin.random.Random @Immutable -data class SnackCollection( - val id: Long, - val name: String, - val snacks: List, - val type: CollectionType = CollectionType.Normal -) +data class SnackCollection(val id: Long, val name: String, val snacks: List, val type: CollectionType = CollectionType.Normal) enum class CollectionType { Normal, Highlight } @@ -37,7 +33,12 @@ object SnackRepo { fun getRelated(@Suppress("UNUSED_PARAMETER") snackId: Long) = related fun getInspiredByCart() = inspiredByCart fun getFilters() = filters + fun getPriceFilters() = priceFilters fun getCart() = cart + fun getSortFilters() = sortFilters + fun getCategoryFilters() = categoryFilters + fun getSortDefault() = sortDefault + fun getLifeStyleFilters() = lifeStyleFilters } /** @@ -48,38 +49,38 @@ private val tastyTreats = SnackCollection( id = 1L, name = "Android's picks", type = CollectionType.Highlight, - snacks = snacks.subList(0, 13) + snacks = snacks.subList(0, 13), ) private val popular = SnackCollection( - id = 2L, + id = Random.nextLong(), name = "Popular on Jetsnack", - snacks = snacks.subList(14, 19) + snacks = snacks.subList(14, 19), ) private val wfhFavs = tastyTreats.copy( - id = 3L, - name = "WFH favourites" + id = Random.nextLong(), + name = "WFH favourites", ) private val newlyAdded = popular.copy( - id = 4L, - name = "Newly Added" + id = Random.nextLong(), + name = "Newly Added", ) private val exclusive = tastyTreats.copy( - id = 5L, - name = "Only on Jetsnack" + id = Random.nextLong(), + name = "Only on Jetsnack", ) private val also = tastyTreats.copy( - id = 6L, - name = "Customers also bought" + id = Random.nextLong(), + name = "Customers also bought", ) private val inspiredByCart = tastyTreats.copy( - id = 7L, - name = "Inspired by your cart" + id = Random.nextLong(), + name = "Inspired by your cart", ) private val snackCollections = listOf( @@ -87,22 +88,19 @@ private val snackCollections = listOf( popular, wfhFavs, newlyAdded, - exclusive + exclusive, ) private val related = listOf( - also, - popular + also.copy(id = Random.nextLong()), + popular.copy(id = Random.nextLong()), ) private val cart = listOf( OrderLine(snacks[4], 2), OrderLine(snacks[6], 3), - OrderLine(snacks[8], 1) + OrderLine(snacks[8], 1), ) @Immutable -data class OrderLine( - val snack: Snack, - val count: Int -) +data class OrderLine(val snack: Snack, val count: Int) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackbarManager.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackbarManager.kt new file mode 100644 index 0000000000..b29a03d664 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackbarManager.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.model + +import androidx.annotation.StringRes +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class Message(val id: Long, @StringRes val messageId: Int) + +/** + * Class responsible for managing Snackbar messages to show on the screen + */ +object SnackbarManager { + + private val _messages: MutableStateFlow> = MutableStateFlow(emptyList()) + val messages: StateFlow> get() = _messages.asStateFlow() + + fun showMessage(@StringRes messageTextId: Int) { + _messages.update { currentMessages -> + currentMessages + Message( + id = UUID.randomUUID().mostSignificantBits, + messageId = messageTextId, + ) + } + } + + fun setMessageShown(messageId: Long) { + _messages.update { currentMessages -> + currentMessages.filterNot { it.id == messageId } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt index fc8b83d907..b98bfae26b 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt @@ -14,38 +14,157 @@ * limitations under the License. */ +@file:OptIn( + ExperimentalSharedTransitionApi::class, +) + package com.example.jetsnack.ui -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.animation.Crossfade +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import com.example.jetsnack.ui.home.Home +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.navArgument +import com.example.jetsnack.ui.components.JetsnackScaffold +import com.example.jetsnack.ui.components.JetsnackSnackbar +import com.example.jetsnack.ui.components.rememberJetsnackScaffoldState +import com.example.jetsnack.ui.home.HomeSections +import com.example.jetsnack.ui.home.JetsnackBottomBar +import com.example.jetsnack.ui.home.addHomeGraph +import com.example.jetsnack.ui.home.composableWithCompositionLocal +import com.example.jetsnack.ui.navigation.MainDestinations +import com.example.jetsnack.ui.navigation.rememberJetsnackNavController import com.example.jetsnack.ui.snackdetail.SnackDetail +import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring +import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring import com.example.jetsnack.ui.theme.JetsnackTheme -import com.example.jetsnack.ui.utils.Navigator -import com.google.accompanist.insets.ProvideWindowInsets +@Preview @Composable -fun JetsnackApp(backDispatcher: OnBackPressedDispatcher) { - val navigator: Navigator = rememberSaveable( - saver = Navigator.saver(backDispatcher) - ) { - Navigator(Destination.Home, backDispatcher) +fun JetsnackApp() { + JetsnackTheme { + val jetsnackNavController = rememberJetsnackNavController() + SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this, + ) { + NavHost( + navController = jetsnackNavController.navController, + startDestination = MainDestinations.HOME_ROUTE, + ) { + composableWithCompositionLocal( + route = MainDestinations.HOME_ROUTE, + ) { backStackEntry -> + MainContainer( + onSnackSelected = jetsnackNavController::navigateToSnackDetail, + ) + } + + composableWithCompositionLocal( + "${MainDestinations.SNACK_DETAIL_ROUTE}/" + + "{${MainDestinations.SNACK_ID_KEY}}" + + "?origin={${MainDestinations.ORIGIN}}", + arguments = listOf( + navArgument(MainDestinations.SNACK_ID_KEY) { + type = NavType.LongType + }, + ), + + ) { backStackEntry -> + val arguments = requireNotNull(backStackEntry.arguments) + val snackId = arguments.getLong(MainDestinations.SNACK_ID_KEY) + val origin = arguments.getString(MainDestinations.ORIGIN) + SnackDetail( + snackId, + origin = origin ?: "", + upPress = jetsnackNavController::upPress, + ) + } + } + } + } } - val actions = remember(navigator) { Actions(navigator) } - ProvideWindowInsets { - JetsnackTheme { - Crossfade(navigator.current) { destination -> - when (destination) { - Destination.Home -> Home(actions.selectSnack) - is Destination.SnackDetail -> SnackDetail( - snackId = destination.snackId, - upPress = actions.upPress +} + +@Composable +fun MainContainer(modifier: Modifier = Modifier, onSnackSelected: (Long, String, NavBackStackEntry) -> Unit) { + val jetsnackScaffoldState = rememberJetsnackScaffoldState() + val nestedNavController = rememberJetsnackNavController() + val navBackStackEntry by nestedNavController.navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No SharedElementScope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No SharedElementScope found") + JetsnackScaffold( + bottomBar = { + with(animatedVisibilityScope) { + with(sharedTransitionScope) { + JetsnackBottomBar( + tabs = HomeSections.entries.toTypedArray(), + currentRoute = currentRoute ?: HomeSections.FEED.route, + navigateToRoute = nestedNavController::navigateToBottomBarRoute, + modifier = Modifier + .renderInSharedTransitionScopeOverlay( + zIndexInOverlay = 1f, + ) + .animateEnterExit( + enter = fadeIn(nonSpatialExpressiveSpring()) + slideInVertically( + spatialExpressiveSpring(), + ) { + it + }, + exit = fadeOut(nonSpatialExpressiveSpring()) + slideOutVertically( + spatialExpressiveSpring(), + ) { + it + }, + ), ) } } + }, + modifier = modifier, + snackbarHost = { + SnackbarHost( + hostState = it, + modifier = Modifier.systemBarsPadding(), + snackbar = { snackbarData -> JetsnackSnackbar(snackbarData) }, + ) + }, + snackBarHostState = jetsnackScaffoldState.snackBarHostState, + ) { padding -> + NavHost( + navController = nestedNavController.navController, + startDestination = HomeSections.FEED.route, + ) { + addHomeGraph( + onSnackSelected = onSnackSelected, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding), + ) } } } + +val LocalNavAnimatedVisibilityScope = compositionLocalOf { null } +val LocalSharedTransitionScope = compositionLocalOf { null } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/MainActivity.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/MainActivity.kt index 07aea3c7d6..d2a1e96131 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/MainActivity.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/MainActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2020-2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,27 +16,42 @@ package com.example.jetsnack.ui +import android.appwidget.AppWidgetManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.core.view.WindowCompat -import com.example.jetsnack.ui.utils.LocalSysUiController -import com.example.jetsnack.ui.utils.SystemUiController +import androidx.activity.enableEdgeToEdge +import androidx.annotation.RequiresApi +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.lifecycle.lifecycleScope +import com.example.jetsnack.widget.RecentOrdersWidgetReceiver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) - - // This app draws behind the system bars, so we want to handle fitting system windows - WindowCompat.setDecorFitsSystemWindows(window, false) - - setContent { - val systemUiController = remember { SystemUiController(window) } - CompositionLocalProvider(LocalSysUiController provides systemUiController) { - JetsnackApp(onBackPressedDispatcher) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + lifecycleScope.launch(Dispatchers.Default) { + setWidgetPreviews() } } + setContent { JetsnackApp() } + } + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + suspend fun setWidgetPreviews() { + val receiver = RecentOrdersWidgetReceiver::class + val installedProviders = getSystemService(AppWidgetManager::class.java).installedProviders + val providerInfo = installedProviders.firstOrNull { + it.provider.className == + receiver.qualifiedName + } + providerInfo?.generatedPreviewCategories.takeIf { it == 0 }?.let { + // Set previews if this provider if unset + GlanceAppWidgetManager(this).setWidgetPreviews(receiver) + } } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/NavGraph.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/NavGraph.kt deleted file mode 100644 index 19b8cc7425..0000000000 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/NavGraph.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetsnack.ui - -import android.os.Parcelable -import androidx.compose.runtime.Immutable -import com.example.jetsnack.ui.utils.Navigator -import kotlinx.parcelize.Parcelize - -/** - * Models the screens in the app and any arguments they require. - */ -sealed class Destination : Parcelable { - @Parcelize - object Home : Destination() - - @Immutable - @Parcelize - data class SnackDetail(val snackId: Long) : Destination() -} - -/** - * Models the navigation actions in the app. - */ -class Actions(navigator: Navigator) { - val selectSnack: (Long) -> Unit = { snackId: Long -> - navigator.navigate(Destination.SnackDetail(snackId)) - } - val upPress: () -> Unit = { - navigator.back() - } -} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/SnackSharedElementKey.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/SnackSharedElementKey.kt new file mode 100644 index 0000000000..c07ed5d027 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/SnackSharedElementKey.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.ui + +data class SnackSharedElementKey(val snackId: Long, val origin: String, val type: SnackSharedElementType) + +enum class SnackSharedElementType { + Bounds, + Image, + Title, + Tagline, + Background, +} + +object FilterSharedElementKey diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Button.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Button.kt index 6500ffdaad..794bd3f1d7 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Button.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Button.kt @@ -16,6 +16,7 @@ package com.example.jetsnack.ui.components +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -28,10 +29,11 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ProvideTextStyle -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -39,12 +41,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview import com.example.jetsnack.ui.theme.JetsnackTheme @Composable - fun JetsnackButton( onClick: () -> Unit, modifier: Modifier = Modifier, @@ -57,7 +60,7 @@ fun JetsnackButton( contentColor: Color = JetsnackTheme.colors.textInteractive, disabledContentColor: Color = JetsnackTheme.colors.textHelp, contentPadding: PaddingValues = ButtonDefaults.ContentPadding, - content: @Composable RowScope.() -> Unit + content: @Composable RowScope.() -> Unit, ) { JetsnackSurface( shape = shape, @@ -68,34 +71,60 @@ fun JetsnackButton( .clip(shape) .background( Brush.horizontalGradient( - colors = if (enabled) backgroundGradient else disabledBackgroundGradient - ) + colors = if (enabled) backgroundGradient else disabledBackgroundGradient, + ), ) .clickable( onClick = onClick, enabled = enabled, role = Role.Button, interactionSource = interactionSource, - indication = null - ) + indication = null, + ), ) { ProvideTextStyle( - value = MaterialTheme.typography.button + value = MaterialTheme.typography.labelLarge, ) { Row( Modifier .defaultMinSize( minWidth = ButtonDefaults.MinWidth, - minHeight = ButtonDefaults.MinHeight + minHeight = ButtonDefaults.MinHeight, ) - .indication(interactionSource, rememberRipple()) + .indication(interactionSource, ripple()) .padding(contentPadding), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, - content = content + content = content, ) } } } private val ButtonShape = RoundedCornerShape(percent = 50) + +@Preview("default", "round") +@Preview("dark theme", "round", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", "round", fontScale = 2f) +@Composable +private fun ButtonPreview() { + JetsnackTheme { + JetsnackButton(onClick = {}) { + Text(text = "Demo") + } + } +} + +@Preview("default", "rectangle") +@Preview("dark theme", "rectangle", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", "rectangle", fontScale = 2f) +@Composable +private fun RectangleButtonPreview() { + JetsnackTheme { + JetsnackButton( + onClick = {}, shape = RectangleShape, + ) { + Text(text = "Demo") + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Card.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Card.kt index 27bc0aec40..8507472164 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Card.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Card.kt @@ -16,12 +16,16 @@ package com.example.jetsnack.ui.components +import android.content.res.Configuration import androidx.compose.foundation.BorderStroke -import androidx.compose.material.MaterialTheme +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.jetsnack.ui.theme.JetsnackTheme @@ -33,8 +37,8 @@ fun JetsnackCard( color: Color = JetsnackTheme.colors.uiBackground, contentColor: Color = JetsnackTheme.colors.textPrimary, border: BorderStroke? = null, - elevation: Dp = 1.dp, - content: @Composable () -> Unit + elevation: Dp = 4.dp, + content: @Composable () -> Unit, ) { JetsnackSurface( modifier = modifier, @@ -43,6 +47,18 @@ fun JetsnackCard( contentColor = contentColor, elevation = elevation, border = border, - content = content + content = content, ) } + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +private fun CardPreview() { + JetsnackTheme { + JetsnackCard { + Text(text = "Demo", modifier = Modifier.padding(16.dp)) + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Divider.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Divider.kt index ba5169a50c..089313675a 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Divider.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Divider.kt @@ -16,10 +16,15 @@ package com.example.jetsnack.ui.components -import androidx.compose.material.Divider +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.jetsnack.ui.theme.JetsnackTheme @@ -29,14 +34,23 @@ fun JetsnackDivider( modifier: Modifier = Modifier, color: Color = JetsnackTheme.colors.uiBorder.copy(alpha = DividerAlpha), thickness: Dp = 1.dp, - startIndent: Dp = 0.dp ) { - Divider( + HorizontalDivider( modifier = modifier, color = color, thickness = thickness, - startIndent = startIndent ) } private const val DividerAlpha = 0.12f + +@Preview("default", showBackground = true) +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun DividerPreview() { + JetsnackTheme { + Box(Modifier.size(height = 10.dp, width = 100.dp)) { + JetsnackDivider(Modifier.align(Alignment.Center)) + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Filters.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Filters.kt index fdef0ce8d5..30121ea109 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Filters.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Filters.kt @@ -14,105 +14,168 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.example.jetsnack.ui.components +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.jetsnack.R import com.example.jetsnack.model.Filter +import com.example.jetsnack.ui.FilterSharedElementKey import com.example.jetsnack.ui.theme.JetsnackTheme @Composable -fun FilterBar(filters: List) { - LazyRow( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(start = 8.dp, end = 8.dp), - modifier = Modifier.heightIn(min = 56.dp) - ) { - item { - IconButton(onClick = { /* todo */ }) { - Icon( - imageVector = Icons.Rounded.FilterList, - tint = JetsnackTheme.colors.brand, - contentDescription = stringResource(R.string.label_filters), - modifier = Modifier.diagonalGradientBorder( - colors = JetsnackTheme.colors.interactiveSecondary, - shape = CircleShape - ) - ) +fun FilterBar( + filters: List, + onShowFilters: () -> Unit, + filterScreenVisible: Boolean, + sharedTransitionScope: SharedTransitionScope, +) { + with(sharedTransitionScope) { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 12.dp, end = 8.dp), + modifier = Modifier.heightIn(min = 56.dp), + ) { + item { + AnimatedVisibility(visible = !filterScreenVisible) { + IconButton( + onClick = onShowFilters, + modifier = Modifier + .sharedBounds( + rememberSharedContentState(FilterSharedElementKey), + animatedVisibilityScope = this@AnimatedVisibility, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + ), + ) { + Icon( + painterResource(R.drawable.ic_filter_list), + tint = JetsnackTheme.colors.brand, + contentDescription = stringResource(R.string.label_filters), + modifier = Modifier.diagonalGradientBorder( + colors = JetsnackTheme.colors.interactiveSecondary, + shape = CircleShape, + ), + ) + } + } + } + items(filters) { filter -> + FilterChip(filter = filter, shape = MaterialTheme.shapes.small) } - } - items(filters) { filter -> - FilterChip(filter) } } } @Composable -fun FilterChip( - filter: Filter, - modifier: Modifier = Modifier, - shape: Shape = MaterialTheme.shapes.small -) { +fun FilterChip(filter: Filter, modifier: Modifier = Modifier, shape: Shape = MaterialTheme.shapes.small) { val (selected, setSelected) = filter.enabled val backgroundColor by animateColorAsState( - if (selected) JetsnackTheme.colors.brand else JetsnackTheme.colors.uiBackground + if (selected) JetsnackTheme.colors.brandSecondary else JetsnackTheme.colors.uiBackground, + label = "background color", ) val border = Modifier.fadeInDiagonalGradientBorder( showBorder = !selected, colors = JetsnackTheme.colors.interactiveSecondary, - shape = shape + shape = shape, ) val textColor by animateColorAsState( - if (selected) JetsnackTheme.colors.textInteractive else JetsnackTheme.colors.textSecondary + if (selected) Color.Black else JetsnackTheme.colors.textSecondary, + label = "text color", ) + JetsnackSurface( - modifier = modifier - .height(28.dp) - .then(border), + modifier = modifier, color = backgroundColor, contentColor = textColor, shape = shape, - elevation = 2.dp + elevation = 2.dp, ) { + val interactionSource = remember { MutableInteractionSource() } + + val pressed by interactionSource.collectIsPressedAsState() + val backgroundPressed = + if (pressed) { + Modifier.offsetGradientBackground( + JetsnackTheme.colors.interactiveSecondary, + 200f, + 0f, + ) + } else { + Modifier.background(Color.Transparent) + } Box( - modifier = Modifier.toggleable( - value = selected, - onValueChange = setSelected - ) + modifier = Modifier + .toggleable( + value = selected, + onValueChange = setSelected, + interactionSource = interactionSource, + indication = null, + ) + .then(backgroundPressed) + .then(border), ) { Text( text = filter.name, - style = MaterialTheme.typography.caption, + style = MaterialTheme.typography.bodySmall, maxLines = 1, modifier = Modifier.padding( horizontal = 20.dp, - vertical = 6.dp - ) + vertical = 6.dp, + ), ) } } } + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +private fun FilterDisabledPreview() { + JetsnackTheme { + FilterChip(Filter(name = "Demo", enabled = false), Modifier.padding(4.dp)) + } +} + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +private fun FilterEnabledPreview() { + JetsnackTheme { + FilterChip(Filter(name = "Demo", enabled = true)) + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Gradient.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Gradient.kt index ec26fe92f0..1a7fba80c4 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Gradient.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Gradient.kt @@ -21,61 +21,63 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -fun Modifier.diagonalGradientTint( - colors: List, - blendMode: BlendMode -) = drawWithContent { +fun Modifier.diagonalGradientTint(colors: List, blendMode: BlendMode) = drawWithContent { drawContent() drawRect( brush = Brush.linearGradient(colors), - blendMode = blendMode + blendMode = blendMode, ) } -fun Modifier.offsetGradientBackground( - colors: List, - width: Float, - offset: Float = 0f -) = background( +fun Modifier.offsetGradientBackground(colors: List, width: Float, offset: Float = 0f) = background( Brush.horizontalGradient( - colors, + colors = colors, startX = -offset, endX = width - offset, - tileMode = TileMode.Mirror - ) + tileMode = TileMode.Mirror, + ), ) -fun Modifier.diagonalGradientBorder( - colors: List, - borderSize: Dp = 2.dp, - shape: Shape -) = border( +fun Modifier.offsetGradientBackground(colors: List, width: Density.() -> Float, offset: Density.() -> Float = { 0f }) = drawBehind { + val actualOffset = offset() + + drawRect( + Brush.horizontalGradient( + colors = colors, + startX = -actualOffset, + endX = width() - actualOffset, + tileMode = TileMode.Mirror, + ), + ) +} + +fun Modifier.diagonalGradientBorder(colors: List, borderSize: Dp = 2.dp, shape: Shape) = border( width = borderSize, brush = Brush.linearGradient(colors), - shape = shape + shape = shape, ) -fun Modifier.fadeInDiagonalGradientBorder( - showBorder: Boolean, - colors: List, - borderSize: Dp = 2.dp, - shape: Shape -) = composed { +fun Modifier.fadeInDiagonalGradientBorder(showBorder: Boolean, colors: List, borderSize: Dp = 2.dp, shape: Shape) = composed { val animatedColors = List(colors.size) { i -> - animateColorAsState(if (showBorder) colors[i] else colors[i].copy(alpha = 0f)).value + animateColorAsState( + if (showBorder) colors[i] else colors[i].copy(alpha = 0f), + label = "animated color", + ).value } diagonalGradientBorder( colors = animatedColors, borderSize = borderSize, - shape = shape + shape = shape, ) } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/GradientTintedIconButton.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/GradientTintedIconButton.kt index 8366eb1a60..39ebb9070e 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/GradientTintedIconButton.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/GradientTintedIconButton.kt @@ -16,33 +16,96 @@ package com.example.jetsnack.ui.components -import androidx.compose.material.Icon -import androidx.compose.material.IconButton +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetsnack.R import com.example.jetsnack.ui.theme.JetsnackTheme @Composable fun JetsnackGradientTintedIconButton( - imageVector: ImageVector, + @DrawableRes iconResourceId: Int, onClick: () -> Unit, contentDescription: String?, modifier: Modifier = Modifier, - colors: List = JetsnackTheme.colors.interactiveSecondary + colors: List = JetsnackTheme.colors.interactiveSecondary, ) { + val interactionSource = remember { MutableInteractionSource() } + // This should use a layer + srcIn but needs investigation + val border = Modifier.fadeInDiagonalGradientBorder( + showBorder = true, + colors = JetsnackTheme.colors.interactiveSecondary, + shape = CircleShape, + ) + val pressed by interactionSource.collectIsPressedAsState() + val background = if (pressed) { + Modifier.offsetGradientBackground(colors, 200f, 0f) + } else { + Modifier.background(JetsnackTheme.colors.uiBackground) + } val blendMode = if (JetsnackTheme.colors.isDark) BlendMode.Darken else BlendMode.Plus - IconButton(onClick = onClick, modifier) { + val modifierColor = if (pressed) { + Modifier.diagonalGradientTint( + colors = listOf( + JetsnackTheme.colors.textSecondary, + JetsnackTheme.colors.textSecondary, + ), + blendMode = blendMode, + ) + } else { + Modifier.diagonalGradientTint( + colors = colors, + blendMode = blendMode, + ) + } + Surface( + modifier = modifier + .clickable( + onClick = onClick, + interactionSource = interactionSource, + indication = null, + ) + .clip(CircleShape) + .then(border) + .then(background), + color = Color.Transparent, + ) { Icon( - imageVector = imageVector, + painter = painterResource(id = iconResourceId), contentDescription = contentDescription, - modifier = Modifier.diagonalGradientTint( - colors = colors, - blendMode = blendMode - ) + modifier = modifierColor, + ) + } +} + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun GradientTintedIconButtonPreview() { + JetsnackTheme { + JetsnackGradientTintedIconButton( + iconResourceId = R.drawable.ic_add, + onClick = {}, + contentDescription = "Demo", + modifier = Modifier.padding(4.dp), ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt index 00bcb470b0..0fd9f27839 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt @@ -24,20 +24,16 @@ import androidx.compose.ui.layout.Layout * A simple grid which lays elements out vertically in evenly sized [columns]. */ @Composable -fun VerticalGrid( - modifier: Modifier = Modifier, - columns: Int = 2, - content: @Composable () -> Unit -) { +fun VerticalGrid(modifier: Modifier = Modifier, columns: Int = 2, content: @Composable () -> Unit) { Layout( content = content, - modifier = modifier + modifier = modifier, ) { measurables, constraints -> val itemWidth = constraints.maxWidth / columns // Keep given height constraints, but set an exact width val itemConstraints = constraints.copy( minWidth = itemWidth, - maxWidth = itemWidth + maxWidth = itemWidth, ) // Measure each item with these constraints val placeables = measurables.map { it.measure(itemConstraints) } @@ -51,15 +47,15 @@ fun VerticalGrid( .coerceAtMost(constraints.maxHeight) layout( width = constraints.maxWidth, - height = height + height = height, ) { // Track the Y co-ord per column we have placed up to val columnY = Array(columns) { 0 } placeables.forEachIndexed { index, placeable -> val column = index % columns - placeable.place( + placeable.placeRelative( x = column * itemWidth, - y = columnY[column] + y = columnY[column], ) columnY[column] += placeable.height } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/QuantitySelector.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/QuantitySelector.kt index f852eac663..4952bd2768 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/QuantitySelector.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/QuantitySelector.kt @@ -16,86 +16,72 @@ package com.example.jetsnack.ui.components +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.ContentAlpha -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AddCircleOutline -import androidx.compose.material.icons.outlined.RemoveCircleOutline +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.constraintlayout.compose.ChainStyle -import androidx.constraintlayout.compose.ConstraintLayout import com.example.jetsnack.R import com.example.jetsnack.ui.theme.JetsnackTheme @Composable -fun QuantitySelector( - count: Int, - decreaseItemCount: () -> Unit, - increaseItemCount: () -> Unit, - modifier: Modifier = Modifier -) { - ConstraintLayout(modifier = modifier) { - val (qty, minus, quantity, plus) = createRefs() - createHorizontalChain(qty, minus, quantity, plus, chainStyle = ChainStyle.Packed) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(R.string.quantity), - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier.constrainAs(qty) { - start.linkTo(parent.start) - linkTo(top = parent.top, bottom = parent.bottom) - } - ) - } +fun QuantitySelector(count: Int, decreaseItemCount: () -> Unit, increaseItemCount: () -> Unit, modifier: Modifier = Modifier) { + Row(modifier = modifier) { + Text( + text = stringResource(R.string.quantity), + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textSecondary, + fontWeight = FontWeight.Normal, + modifier = Modifier + .padding(end = 18.dp) + .align(Alignment.CenterVertically), + ) JetsnackGradientTintedIconButton( - imageVector = Icons.Outlined.RemoveCircleOutline, + iconResourceId = R.drawable.ic_remove, onClick = decreaseItemCount, contentDescription = stringResource(R.string.label_decrease), - modifier = Modifier.constrainAs(minus) { - centerVerticallyTo(quantity) - linkTo(top = parent.top, bottom = parent.bottom) - } + modifier = Modifier.align(Alignment.CenterVertically), ) Crossfade( targetState = count, modifier = Modifier - .constrainAs(quantity) { baseline.linkTo(qty.baseline) } + .align(Alignment.CenterVertically), ) { Text( text = "$it", - style = MaterialTheme.typography.subtitle2, + style = MaterialTheme.typography.titleSmall, fontSize = 18.sp, color = JetsnackTheme.colors.textPrimary, textAlign = TextAlign.Center, - modifier = Modifier.widthIn(min = 24.dp) + modifier = Modifier.widthIn(min = 24.dp), ) } JetsnackGradientTintedIconButton( - imageVector = Icons.Outlined.AddCircleOutline, + iconResourceId = R.drawable.ic_add, onClick = increaseItemCount, contentDescription = stringResource(R.string.label_increase), - modifier = Modifier.constrainAs(plus) { - end.linkTo(parent.end) - centerVerticallyTo(quantity) - linkTo(top = parent.top, bottom = parent.bottom) - } + modifier = Modifier.align(Alignment.CenterVertically), ) } } -@Preview +@Preview("default") +@Preview("dark theme", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable fun QuantitySelectorPreview() { JetsnackTheme { @@ -104,3 +90,15 @@ fun QuantitySelectorPreview() { } } } + +@Preview("RTL") +@Composable +fun QuantitySelectorPreviewRtl() { + JetsnackTheme { + JetsnackSurface { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + QuantitySelector(1, {}, {}) + } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Scaffold.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Scaffold.kt index 337a02d8c6..7ab58f5ec7 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Scaffold.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Scaffold.kt @@ -16,65 +16,105 @@ package com.example.jetsnack.ui.components -import androidx.compose.foundation.layout.ColumnScope +import android.content.res.Resources import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.material.DrawerDefaults -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FabPosition -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.ScaffoldState -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +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.graphics.Shape -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import com.example.jetsnack.model.SnackbarManager import com.example.jetsnack.ui.theme.JetsnackTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** - * Wrap Material [androidx.compose.material.Scaffold] and set [JetsnackTheme] colors. + * Wrap Material [androidx.compose.material3.Scaffold] and set [JetsnackTheme] colors. */ -@OptIn(ExperimentalMaterialApi::class) @Composable fun JetsnackScaffold( modifier: Modifier = Modifier, - scaffoldState: ScaffoldState = rememberScaffoldState(), + snackBarHostState: SnackbarHostState = remember { SnackbarHostState() }, topBar: @Composable (() -> Unit) = {}, bottomBar: @Composable (() -> Unit) = {}, snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, floatingActionButton: @Composable (() -> Unit) = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, - isFloatingActionButtonDocked: Boolean = false, - drawerContent: @Composable (ColumnScope.() -> Unit)? = null, - drawerShape: Shape = MaterialTheme.shapes.large, - drawerElevation: Dp = DrawerDefaults.Elevation, - drawerBackgroundColor: Color = JetsnackTheme.colors.uiBackground, - drawerContentColor: Color = JetsnackTheme.colors.textSecondary, - drawerScrimColor: Color = JetsnackTheme.colors.uiBorder, backgroundColor: Color = JetsnackTheme.colors.uiBackground, contentColor: Color = JetsnackTheme.colors.textSecondary, - content: @Composable (PaddingValues) -> Unit + content: @Composable (PaddingValues) -> Unit, ) { Scaffold( modifier = modifier, - scaffoldState = scaffoldState, topBar = topBar, bottomBar = bottomBar, - snackbarHost = snackbarHost, + snackbarHost = { + snackbarHost(snackBarHostState) + }, floatingActionButton = floatingActionButton, floatingActionButtonPosition = floatingActionButtonPosition, - isFloatingActionButtonDocked = isFloatingActionButtonDocked, - drawerContent = drawerContent, - drawerShape = drawerShape, - drawerElevation = drawerElevation, - drawerBackgroundColor = drawerBackgroundColor, - drawerContentColor = drawerContentColor, - drawerScrimColor = drawerScrimColor, - backgroundColor = backgroundColor, + containerColor = backgroundColor, contentColor = contentColor, - content = content + content = content, ) } + +/** + * Remember and creates an instance of [JetsnackScaffoldState] + */ +@Composable +fun rememberJetsnackScaffoldState( + snackBarHostState: SnackbarHostState = remember { SnackbarHostState() }, + snackbarManager: SnackbarManager = SnackbarManager, + resources: Resources = resources(), + coroutineScope: CoroutineScope = rememberCoroutineScope(), +): JetsnackScaffoldState = remember(snackBarHostState, snackbarManager, resources, coroutineScope) { + JetsnackScaffoldState(snackBarHostState, snackbarManager, resources, coroutineScope) +} + +/** + * Responsible for holding [ScaffoldState], handles the logic of showing snackbar messages + */ +@Stable +class JetsnackScaffoldState( + val snackBarHostState: SnackbarHostState, + private val snackbarManager: SnackbarManager, + private val resources: Resources, + coroutineScope: CoroutineScope, +) { + // Process snackbars coming from SnackbarManager + init { + coroutineScope.launch { + snackbarManager.messages.collect { currentMessages -> + if (currentMessages.isNotEmpty()) { + val message = currentMessages[0] + val text = resources.getText(message.messageId) + // Notify the SnackbarManager so it can remove the current message from the list + snackbarManager.setMessageShown(message.id) + // Display the snackbar on the screen. `showSnackbar` is a function + // that suspends until the snackbar disappears from the screen + snackBarHostState.showSnackbar(text.toString()) + } + } + } + } +} + +/** + * A composable function that returns the [Resources]. It will be recomposed when `Configuration` + * gets updated. + */ +@Composable +@ReadOnlyComposable +private fun resources(): Resources { + LocalConfiguration.current + return LocalContext.current.resources +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snackbar.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snackbar.kt new file mode 100644 index 0000000000..1f1d6a3216 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snackbar.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import com.example.jetsnack.ui.theme.JetsnackTheme + +/** + * An alternative to [androidx.compose.material3.Snackbar] utilizing + * [com.example.jetsnack.ui.theme.JetsnackColors] + */ +@Composable +fun JetsnackSnackbar( + snackbarData: SnackbarData, + modifier: Modifier = Modifier, + actionOnNewLine: Boolean = false, + shape: Shape = MaterialTheme.shapes.small, + backgroundColor: Color = JetsnackTheme.colors.uiBackground, + contentColor: Color = JetsnackTheme.colors.textSecondary, + actionColor: Color = JetsnackTheme.colors.brand, +) { + Snackbar( + snackbarData = snackbarData, + modifier = modifier, + actionOnNewLine = actionOnNewLine, + shape = shape, + containerColor = backgroundColor, + contentColor = contentColor, + actionColor = actionColor, + ) +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt index ee76bd1a39..028ee2b1ce 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt @@ -14,8 +14,21 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.example.jetsnack.ui.components +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -33,290 +46,462 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowForward +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest import com.example.jetsnack.R import com.example.jetsnack.model.CollectionType import com.example.jetsnack.model.Snack import com.example.jetsnack.model.SnackCollection import com.example.jetsnack.model.snacks +import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope +import com.example.jetsnack.ui.LocalSharedTransitionScope +import com.example.jetsnack.ui.SnackSharedElementKey +import com.example.jetsnack.ui.SnackSharedElementType +import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring +import com.example.jetsnack.ui.snackdetail.snackDetailBoundsTransform import com.example.jetsnack.ui.theme.JetsnackTheme -import com.google.accompanist.coil.CoilImage private val HighlightCardWidth = 170.dp private val HighlightCardPadding = 16.dp - -// The Cards show a gradient which spans 3 cards and scrolls with parallax. -private val gradientWidth - @Composable - get() = with(LocalDensity.current) { - (3 * (HighlightCardWidth + HighlightCardPadding).toPx()) - } +private val Density.cardWidthWithPaddingPx + get() = (HighlightCardWidth + HighlightCardPadding).toPx() @Composable fun SnackCollection( snackCollection: SnackCollection, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier, index: Int = 0, - highlight: Boolean = true + highlight: Boolean = true, ) { Column(modifier = modifier) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .heightIn(min = 56.dp) - .padding(start = 24.dp) + .padding(start = 24.dp), ) { Text( text = snackCollection.name, - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.brand, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .weight(1f) - .wrapContentWidth(Alignment.Start) + .wrapContentWidth(Alignment.Start), ) IconButton( onClick = { /* todo */ }, - modifier = Modifier.align(Alignment.CenterVertically) + modifier = Modifier.align(Alignment.CenterVertically), ) { Icon( - imageVector = Icons.Outlined.ArrowForward, + painter = painterResource(id = R.drawable.ic_arrow_back), tint = JetsnackTheme.colors.brand, - contentDescription = null + contentDescription = null, ) } } if (highlight && snackCollection.type == CollectionType.Highlight) { - HighlightedSnacks(index, snackCollection.snacks, onSnackClick) + HighlightedSnacks(snackCollection.id, index, snackCollection.snacks, onSnackClick) } else { - Snacks(snackCollection.snacks, onSnackClick) + Snacks(snackCollection.id, snackCollection.snacks, onSnackClick) } } } @Composable private fun HighlightedSnacks( + snackCollectionId: Long, index: Int, snacks: List, - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier + onSnackClick: (Long, String) -> Unit, + modifier: Modifier = Modifier, ) { - val scroll = rememberScrollState(0) - val gradient = when (index % 2) { + val rowState = rememberLazyListState() + val cardWidthWithPaddingPx = with(LocalDensity.current) { cardWidthWithPaddingPx } + + val scrollProvider = { + // Simple calculation of scroll distance for homogenous item types with the same width. + val offsetFromStart = cardWidthWithPaddingPx * rowState.firstVisibleItemIndex + offsetFromStart + rowState.firstVisibleItemScrollOffset + } + + val gradient = when ((index / 2) % 2) { 0 -> JetsnackTheme.colors.gradient6_1 else -> JetsnackTheme.colors.gradient6_2 } - // The Cards show a gradient which spans 3 cards and scrolls with parallax. - val gradientWidth = with(LocalDensity.current) { - (3 * (HighlightCardWidth + HighlightCardPadding).toPx()) - } + LazyRow( + state = rowState, modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp) + contentPadding = PaddingValues(start = 24.dp, end = 24.dp), ) { itemsIndexed(snacks) { index, snack -> HighlightSnackItem( - snack, - onSnackClick, - index, - gradient, - gradientWidth, - scroll.value + snackCollectionId = snackCollectionId, + snack = snack, + onSnackClick = onSnackClick, + index = index, + gradient = gradient, + scrollProvider = scrollProvider, ) } } } @Composable -private fun Snacks( - snacks: List, - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier -) { +private fun Snacks(snackCollectionId: Long, snacks: List, onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier) { LazyRow( modifier = modifier, - contentPadding = PaddingValues(start = 12.dp, end = 12.dp) + contentPadding = PaddingValues(start = 12.dp, end = 12.dp), ) { items(snacks) { snack -> - SnackItem(snack, onSnackClick) + SnackItem(snack, snackCollectionId, onSnackClick) } } } @Composable -fun SnackItem( - snack: Snack, - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier -) { +fun SnackItem(snack: Snack, snackCollectionId: Long, onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier) { JetsnackSurface( shape = MaterialTheme.shapes.medium, modifier = modifier.padding( start = 4.dp, end = 4.dp, - bottom = 8.dp - ) + bottom = 8.dp, + ), + ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .clickable(onClick = { onSnackClick(snack.id) }) - .padding(8.dp) - ) { - SnackImage( - imageUrl = snack.imageUrl, - elevation = 4.dp, - contentDescription = null, - modifier = Modifier.size(120.dp) - ) - Text( - text = snack.name, - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier.padding(top = 8.dp) - ) + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No sharedTransitionScope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No animatedVisibilityScope found") + + with(sharedTransitionScope) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clickable(onClick = { + onSnackClick(snack.id, snackCollectionId.toString()) + }) + .padding(8.dp), + ) { + SnackImage( + imageRes = snack.imageRes, + elevation = 1.dp, + contentDescription = null, + modifier = Modifier + .size(120.dp) + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Image, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + ), + ) + Text( + text = snack.name, + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textSecondary, + modifier = Modifier + .padding(top = 8.dp) + .wrapContentWidth() + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Title, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), + boundsTransform = snackDetailBoundsTransform, + ), + ) + } } } } @Composable private fun HighlightSnackItem( + snackCollectionId: Long, snack: Snack, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, index: Int, gradient: List, - gradientWidth: Float, - scroll: Int, - modifier: Modifier = Modifier + scrollProvider: () -> Float, + modifier: Modifier = Modifier, ) { - val left = index * with(LocalDensity.current) { - (HighlightCardWidth + HighlightCardPadding).toPx() - } - JetsnackCard( - elevation = 4.dp, - modifier = modifier - .size( - width = 170.dp, - height = 250.dp - ) - .padding(bottom = 16.dp) - ) { - Column( - modifier = Modifier - .clickable(onClick = { onSnackClick(snack.id) }) - .fillMaxSize() + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No Scope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No Scope found") + with(sharedTransitionScope) { + val roundedCornerAnimation by animatedVisibilityScope.transition + .animateDp(label = "rounded corner") { enterExit: EnterExitState -> + when (enterExit) { + EnterExitState.PreEnter -> 0.dp + EnterExitState.Visible -> 20.dp + EnterExitState.PostExit -> 20.dp + } + } + JetsnackCard( + elevation = 0.dp, + shape = RoundedCornerShape(roundedCornerAnimation), + modifier = modifier + .padding(bottom = 16.dp) + .sharedBounds( + sharedContentState = rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Bounds, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + clipInOverlayDuringTransition = OverlayClip( + RoundedCornerShape( + roundedCornerAnimation, + ), + ), + enter = fadeIn(), + exit = fadeOut(), + ) + .size( + width = HighlightCardWidth, + height = 250.dp, + ) + .border( + 1.dp, + JetsnackTheme.colors.uiBorder.copy(alpha = 0.12f), + RoundedCornerShape(roundedCornerAnimation), + ), + ) { - Box( + Column( modifier = Modifier - .height(160.dp) - .fillMaxWidth() + .clickable(onClick = { + onSnackClick( + snack.id, + snackCollectionId.toString(), + ) + }) + .fillMaxSize(), + ) { - val gradientOffset = left - (scroll / 3f) Box( modifier = Modifier - .height(100.dp) - .fillMaxWidth() - .offsetGradientBackground(gradient, gradientWidth, gradientOffset) + .height(160.dp) + .fillMaxWidth(), + ) { + Box( + modifier = Modifier + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Background, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), + ) + .height(100.dp) + .fillMaxWidth() + .offsetGradientBackground( + colors = gradient, + width = { + // The Cards show a gradient which spans 6 cards and + // scrolls with parallax. + 6 * cardWidthWithPaddingPx + }, + offset = { + val left = index * cardWidthWithPaddingPx + val gradientOffset = left - (scrollProvider() / 3f) + gradientOffset + }, + ), + ) + + SnackImage( + imageRes = snack.imageRes, + contentDescription = null, + modifier = Modifier + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Image, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + exit = fadeOut(nonSpatialExpressiveSpring()), + enter = fadeIn(nonSpatialExpressiveSpring()), + boundsTransform = snackDetailBoundsTransform, + ) + .align(Alignment.BottomCenter) + .size(120.dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = snack.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + color = JetsnackTheme.colors.textSecondary, + modifier = Modifier + .padding(horizontal = 16.dp) + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Title, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + boundsTransform = snackDetailBoundsTransform, + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), + ) + .wrapContentWidth(), ) - SnackImage( - imageUrl = snack.imageUrl, - contentDescription = null, + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = snack.tagline, + style = MaterialTheme.typography.bodyLarge, + color = JetsnackTheme.colors.textHelp, modifier = Modifier - .size(120.dp) - .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp) + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Tagline, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + boundsTransform = snackDetailBoundsTransform, + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), + ) + .wrapContentWidth(), ) } - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = snack.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.h6, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = snack.tagline, - style = MaterialTheme.typography.body1, - color = JetsnackTheme.colors.textHelp, - modifier = Modifier.padding(horizontal = 16.dp) - ) } } } +@Composable +fun debugPlaceholder(@DrawableRes debugPreview: Int) = if (LocalInspectionMode.current) { + painterResource(id = debugPreview) +} else { + null +} + @Composable fun SnackImage( - imageUrl: String, + @DrawableRes + imageRes: Int, contentDescription: String?, modifier: Modifier = Modifier, - elevation: Dp = 0.dp + elevation: Dp = 0.dp, ) { JetsnackSurface( - color = Color.LightGray, elevation = elevation, shape = CircleShape, - modifier = modifier + modifier = modifier, ) { - CoilImage( - data = imageUrl, + + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageRes) + .crossfade(true) + .build(), + placeholder = debugPlaceholder(debugPreview = R.drawable.placeholder), contentDescription = contentDescription, - previewPlaceholder = R.drawable.placeholder, + modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() ) } } -@Preview("Highlight snack card") +@Preview("default") +@Preview("dark theme", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable fun SnackCardPreview() { - JetsnackTheme { - val snack = snacks.first() + val snack = snacks.first() + JetsnackPreviewWrapper { HighlightSnackItem( + snackCollectionId = 1, snack = snack, - onSnackClick = { }, + onSnackClick = { _, _ -> }, index = 0, gradient = JetsnackTheme.colors.gradient6_1, - gradientWidth = gradientWidth, - scroll = 0 + scrollProvider = { 0f }, ) } } -@Preview("Highlight snack card • Dark Theme") @Composable -fun SnackCardDarkPreview() { - JetsnackTheme(darkTheme = true) { - val snack = snacks.first() - HighlightSnackItem( - snack = snack, - onSnackClick = { }, - index = 0, - gradient = JetsnackTheme.colors.gradient6_1, - gradientWidth = gradientWidth, - scroll = 0 - ) +fun JetsnackPreviewWrapper(content: @Composable () -> Unit) { + JetsnackTheme { + SharedTransitionLayout { + AnimatedVisibility(visible = true) { + CompositionLocalProvider( + LocalSharedTransitionScope provides this@SharedTransitionLayout, + LocalNavAnimatedVisibilityScope provides this, + ) { + content() + } + } + } } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt index 584c446367..1104c51f1e 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt @@ -20,7 +20,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box -import androidx.compose.material.LocalContentColor +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier @@ -37,7 +37,7 @@ import com.example.jetsnack.ui.theme.JetsnackTheme import kotlin.math.ln /** - * An alternative to [androidx.compose.material.Surface] utilizing + * An alternative to [androidx.compose.material3.Surface] utilizing * [com.example.jetsnack.ui.theme.JetsnackColors] */ @Composable @@ -48,17 +48,18 @@ fun JetsnackSurface( contentColor: Color = JetsnackTheme.colors.textSecondary, border: BorderStroke? = null, elevation: Dp = 0.dp, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { Box( - modifier = modifier.shadow(elevation = elevation, shape = shape, clip = false) + modifier = modifier + .shadow(elevation = elevation, shape = shape, clip = false) .zIndex(elevation.value) .then(if (border != null) Modifier.border(border, shape) else Modifier) .background( color = getBackgroundColorForElevation(color, elevation), - shape = shape + shape = shape, ) - .clip(shape) + .clip(shape), ) { CompositionLocalProvider(LocalContentColor provides contentColor, content = content) } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt index f9b6e514dd..b706ea8bb3 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt @@ -14,59 +14,104 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.example.jetsnack.ui.home +import android.content.res.Configuration +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Column -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview import com.example.jetsnack.R +import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope +import com.example.jetsnack.ui.LocalSharedTransitionScope import com.example.jetsnack.ui.components.JetsnackDivider +import com.example.jetsnack.ui.components.JetsnackPreviewWrapper +import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring import com.example.jetsnack.ui.theme.AlphaNearOpaque import com.example.jetsnack.ui.theme.JetsnackTheme -import com.google.accompanist.insets.statusBarsPadding +@OptIn(ExperimentalMaterial3Api::class) @Composable fun DestinationBar(modifier: Modifier = Modifier) { - Column(modifier = modifier.statusBarsPadding()) { - TopAppBar( - backgroundColor = JetsnackTheme.colors.uiBackground.copy(alpha = AlphaNearOpaque), - contentColor = JetsnackTheme.colors.textSecondary, - elevation = 0.dp - ) { - Text( - text = "Delivery to 1600 Amphitheater Way", - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textSecondary, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) - IconButton( - onClick = { /* todo */ }, - modifier = Modifier.align(Alignment.CenterVertically) + val sharedElementScope = + LocalSharedTransitionScope.current ?: throw IllegalStateException("No shared element scope") + val navAnimatedScope = + LocalNavAnimatedVisibilityScope.current ?: throw IllegalStateException("No nav scope") + with(sharedElementScope) { + with(navAnimatedScope) { + Column( + modifier = modifier + .renderInSharedTransitionScopeOverlay() + .animateEnterExit( + enter = slideInVertically(spatialExpressiveSpring()) { -it * 2 }, + exit = slideOutVertically(spatialExpressiveSpring()) { -it * 2 }, + ), ) { - Icon( - imageVector = Icons.Outlined.ExpandMore, - tint = JetsnackTheme.colors.brand, - contentDescription = stringResource(R.string.label_select_delivery) + TopAppBar( + windowInsets = WindowInsets(0, 0, 0, 0), + title = { + Row { + Text( + text = "Delivery to 1600 Amphitheater Way", + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textSecondary, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + ) + IconButton( + onClick = { /* todo */ }, + modifier = Modifier.align(Alignment.CenterVertically), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_expand_more), + tint = JetsnackTheme.colors.brand, + contentDescription = + stringResource(R.string.label_select_delivery), + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors().copy( + containerColor = JetsnackTheme.colors.uiBackground + .copy(alpha = AlphaNearOpaque), + titleContentColor = JetsnackTheme.colors.textSecondary, + ), ) + JetsnackDivider() } } - JetsnackDivider() + } +} + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +fun PreviewDestinationBar() { + JetsnackPreviewWrapper { + DestinationBar() } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt index 7338d05dd5..10e3125ce5 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,15 +14,31 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.example.jetsnack.ui.home +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -34,20 +50,16 @@ import com.example.jetsnack.ui.components.JetsnackDivider import com.example.jetsnack.ui.components.JetsnackSurface import com.example.jetsnack.ui.components.SnackCollection import com.example.jetsnack.ui.theme.JetsnackTheme -import com.google.accompanist.insets.statusBarsHeight @Composable -fun Feed( - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier -) { +fun Feed(onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier) { val snackCollections = remember { SnackRepo.getSnacks() } val filters = remember { SnackRepo.getFilters() } Feed( snackCollections, filters, onSnackClick, - modifier + modifier, ) } @@ -55,13 +67,33 @@ fun Feed( private fun Feed( snackCollections: List, filters: List, - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier + onSnackClick: (Long, String) -> Unit, + modifier: Modifier = Modifier, ) { JetsnackSurface(modifier = modifier.fillMaxSize()) { - Box { - SnackCollectionList(snackCollections, filters, onSnackClick) - DestinationBar() + var filtersVisible by remember { + mutableStateOf(false) + } + SharedTransitionLayout { + Box { + SnackCollectionList( + snackCollections, + filters, + filtersVisible = filtersVisible, + onFiltersSelected = { + filtersVisible = true + }, + sharedTransitionScope = this@SharedTransitionLayout, + onSnackClick = onSnackClick, + ) + DestinationBar() + AnimatedVisibility(filtersVisible, enter = fadeIn(), exit = fadeOut()) { + FilterScreen( + animatedVisibilityScope = this@AnimatedVisibility, + sharedTransitionScope = this@SharedTransitionLayout, + ) { filtersVisible = false } + } + } } } } @@ -70,39 +102,46 @@ private fun Feed( private fun SnackCollectionList( snackCollections: List, filters: List, - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier + filtersVisible: Boolean, + onFiltersSelected: () -> Unit, + onSnackClick: (Long, String) -> Unit, + sharedTransitionScope: SharedTransitionScope, + modifier: Modifier = Modifier, ) { - LazyColumn(modifier) { + LazyColumn(modifier = modifier) { item { - Spacer(Modifier.statusBarsHeight(additional = 56.dp)) - FilterBar(filters) + Spacer( + Modifier.windowInsetsTopHeight( + WindowInsets.statusBars.add(WindowInsets(top = 56.dp)), + ), + ) + FilterBar( + filters, + sharedTransitionScope = sharedTransitionScope, + filterScreenVisible = filtersVisible, + onShowFilters = onFiltersSelected, + ) } itemsIndexed(snackCollections) { index, snackCollection -> if (index > 0) { JetsnackDivider(thickness = 2.dp) } + SnackCollection( snackCollection = snackCollection, onSnackClick = onSnackClick, - index = index + index = index, ) } } } -@Preview("Home") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable fun HomePreview() { JetsnackTheme { - Feed(onSnackClick = { }) - } -} - -@Preview("Home • Dark Theme") -@Composable -fun HomeDarkPreview() { - JetsnackTheme(darkTheme = true) { - Feed(onSnackClick = { }) + Feed(onSnackClick = { _, _ -> }) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/FilterScreen.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/FilterScreen.kt new file mode 100644 index 0000000000..f2c0dcabc4 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/FilterScreen.kt @@ -0,0 +1,321 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class) + +package com.example.jetsnack.ui.home + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetsnack.R +import com.example.jetsnack.model.Filter +import com.example.jetsnack.model.SnackRepo +import com.example.jetsnack.ui.FilterSharedElementKey +import com.example.jetsnack.ui.components.FilterChip +import com.example.jetsnack.ui.theme.JetsnackTheme + +@Composable +fun FilterScreen(sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, onDismiss: () -> Unit) { + var sortState by remember { mutableStateOf(SnackRepo.getSortDefault()) } + var maxCalories by remember { mutableFloatStateOf(0f) } + val defaultFilter = SnackRepo.getSortDefault() + + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + // capture click + }, + ) { + val priceFilters = remember { SnackRepo.getPriceFilters() } + val categoryFilters = remember { SnackRepo.getCategoryFilters() } + val lifeStyleFilters = remember { SnackRepo.getLifeStyleFilters() } + Spacer( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + onDismiss() + }, + ) + with(sharedTransitionScope) { + Column( + Modifier + .padding(16.dp) + .align(Alignment.Center) + .clip(MaterialTheme.shapes.medium) + .sharedBounds( + rememberSharedContentState(FilterSharedElementKey), + animatedVisibilityScope = animatedVisibilityScope, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + clipInOverlayDuringTransition = OverlayClip(MaterialTheme.shapes.medium), + ) + .wrapContentSize() + .heightIn(max = 450.dp) + .verticalScroll(rememberScrollState()) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { } + .background(JetsnackTheme.colors.uiFloated) + .padding(horizontal = 24.dp, vertical = 16.dp) + .skipToLookaheadSize(), + ) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + IconButton(onClick = onDismiss) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.close), + ) + } + Text( + text = stringResource(id = R.string.label_filters), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(top = 8.dp, end = 48.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + ) + val resetEnabled = sortState != defaultFilter + + IconButton( + onClick = { /* TODO: Open search */ }, + enabled = resetEnabled, + ) { + val fontWeight = if (resetEnabled) { + FontWeight.Bold + } else { + FontWeight.Normal + } + + Text( + text = stringResource(id = R.string.reset), + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + color = JetsnackTheme.colors.uiBackground + .copy(alpha = if (!resetEnabled) 0.38f else 1f), + ) + } + } + + SortFiltersSection( + sortState = sortState, + onFilterChange = { filter -> + sortState = filter.name + }, + ) + FilterChipSection( + title = stringResource(id = R.string.price), + filters = priceFilters, + ) + FilterChipSection( + title = stringResource(id = R.string.category), + filters = categoryFilters, + ) + + MaxCalories( + sliderPosition = maxCalories, + onValueChanged = { newValue -> + maxCalories = newValue + }, + ) + FilterChipSection( + title = stringResource(id = R.string.lifestyle), + filters = lifeStyleFilters, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FilterChipSection(title: String, filters: List) { + FilterTitle(text = title) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 16.dp) + .padding(horizontal = 4.dp), + ) { + filters.forEach { filter -> + FilterChip( + filter = filter, + modifier = Modifier.padding(end = 4.dp, bottom = 8.dp), + ) + } + } +} + +@Composable +fun SortFiltersSection(sortState: String, onFilterChange: (Filter) -> Unit) { + FilterTitle(text = stringResource(id = R.string.sort)) + Column(Modifier.padding(bottom = 24.dp)) { + SortFilters( + sortState = sortState, + onChanged = onFilterChange, + ) + } +} + +@Composable +fun SortFilters(sortFilters: List = SnackRepo.getSortFilters(), sortState: String, onChanged: (Filter) -> Unit) { + + sortFilters.forEach { filter -> + SortOption( + text = filter.name, + icon = filter.icon, + selected = sortState == filter.name, + onClickOption = { + onChanged(filter) + }, + ) + } +} + +@Composable +fun MaxCalories(sliderPosition: Float, onValueChanged: (Float) -> Unit) { + FlowRow { + FilterTitle(text = stringResource(id = R.string.max_calories)) + Text( + text = stringResource(id = R.string.per_serving), + style = MaterialTheme.typography.bodyMedium, + color = JetsnackTheme.colors.brand, + modifier = Modifier.padding(top = 5.dp, start = 10.dp), + ) + } + Slider( + value = sliderPosition, + onValueChange = { newValue -> + onValueChanged(newValue) + }, + valueRange = 0f..300f, + steps = 5, + modifier = Modifier + .fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = JetsnackTheme.colors.brand, + activeTrackColor = JetsnackTheme.colors.brand, + inactiveTrackColor = JetsnackTheme.colors.iconInteractive, + ), + ) +} + +@Composable +fun FilterTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleLarge, + color = JetsnackTheme.colors.brand, + modifier = Modifier.padding(bottom = 8.dp), + ) +} + +@Composable +fun SortOption(text: String, @DrawableRes icon: Int?, onClickOption: () -> Unit, selected: Boolean) { + Row( + modifier = Modifier + .padding(top = 14.dp) + .selectable(selected) { onClickOption() }, + ) { + if (icon != null) { + Icon(painter = painterResource(id = icon), contentDescription = null) + } + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(start = 10.dp) + .weight(1f), + ) + if (selected) { + Icon( + painter = painterResource(id = R.drawable.ic_check), + contentDescription = null, + tint = JetsnackTheme.colors.brand, + ) + } + } +} + +@Preview("filter screen") +@Composable +fun FilterScreenPreview() { + JetsnackTheme { + SharedTransitionLayout { + AnimatedVisibility(true) { + FilterScreen( + animatedVisibilityScope = this, + sharedTransitionScope = this@SharedTransitionLayout, + onDismiss = {}, + ) + } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt index e8dd466f8d..6811d543a4 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2020-2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,156 +16,214 @@ package com.example.jetsnack.ui.home +import androidx.annotation.DrawableRes import androidx.annotation.FloatRange import androidx.annotation.StringRes -import androidx.compose.animation.Crossfade +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AccountCircle -import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material.icons.outlined.ShoppingCart +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import androidx.core.os.ConfigurationCompat +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink import com.example.jetsnack.R -import com.example.jetsnack.ui.components.JetsnackScaffold +import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope import com.example.jetsnack.ui.components.JetsnackSurface import com.example.jetsnack.ui.home.cart.Cart import com.example.jetsnack.ui.home.search.Search +import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring +import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring import com.example.jetsnack.ui.theme.JetsnackTheme -import com.google.accompanist.insets.navigationBarsPadding +import java.util.Locale -@Composable -fun Home(onSnackSelected: (Long) -> Unit) { - val (currentSection, setCurrentSection) = rememberSaveable { - mutableStateOf(HomeSections.Feed) - } - val navItems = HomeSections.values().toList() - JetsnackScaffold( - bottomBar = { - JetsnackBottomNav( - currentSection = currentSection, - onSectionSelected = setCurrentSection, - items = navItems - ) - } - ) { innerPadding -> - val modifier = Modifier.padding(innerPadding) - Crossfade(currentSection) { section -> - when (section) { - HomeSections.Feed -> Feed( - onSnackClick = onSnackSelected, - modifier = modifier - ) - HomeSections.Search -> Search(onSnackSelected, modifier) - HomeSections.Cart -> Cart(onSnackSelected, modifier) - HomeSections.Profile -> Profile(modifier) - } +fun NavGraphBuilder.composableWithCompositionLocal( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + enterTransition: ( + @JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition? + )? = { + fadeIn(nonSpatialExpressiveSpring()) + }, + exitTransition: ( + @JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition? + )? = { + fadeOut(nonSpatialExpressiveSpring()) + }, + popEnterTransition: ( + @JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition? + )? = + enterTransition, + popExitTransition: ( + @JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition? + )? = + exitTransition, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + composable( + route, + arguments, + deepLinks, + enterTransition, + exitTransition, + popEnterTransition, + popExitTransition, + ) { + CompositionLocalProvider( + LocalNavAnimatedVisibilityScope provides this@composable, + ) { + content(it) } } } +fun NavGraphBuilder.addHomeGraph(onSnackSelected: (Long, String, NavBackStackEntry) -> Unit, modifier: Modifier = Modifier) { + composable(HomeSections.FEED.route) { from -> + Feed( + onSnackClick = { id, origin -> onSnackSelected(id, origin, from) }, + modifier, + ) + } + composable(HomeSections.SEARCH.route) { from -> + Search( + onSnackClick = { id, origin -> onSnackSelected(id, origin, from) }, + modifier, + ) + } + composable( + HomeSections.CART.route, + deepLinks = listOf( + navDeepLink { uriPattern = "https://jetsnack.example.com/home/cart" }, + ), + ) { from -> + Cart( + onSnackClick = { id, origin -> onSnackSelected(id, origin, from) }, + modifier, + ) + } + composable(HomeSections.PROFILE.route) { + Profile(modifier) + } +} + +enum class HomeSections(@StringRes val title: Int, @DrawableRes val icon: Int, val route: String) { + FEED(R.string.home_feed, R.drawable.ic_home, "home/feed"), + SEARCH(R.string.home_search, R.drawable.ic_search, "home/search"), + CART(R.string.home_cart, R.drawable.ic_shopping_cart, "home/cart"), + PROFILE(R.string.home_profile, R.drawable.ic_account_circle, "home/profile"), +} + @Composable -private fun JetsnackBottomNav( - currentSection: HomeSections, - onSectionSelected: (HomeSections) -> Unit, - items: List, +fun JetsnackBottomBar( + tabs: Array, + currentRoute: String, + navigateToRoute: (String) -> Unit, + modifier: Modifier = Modifier, color: Color = JetsnackTheme.colors.iconPrimary, - contentColor: Color = JetsnackTheme.colors.iconInteractive + contentColor: Color = JetsnackTheme.colors.iconInteractive, ) { + val routes = remember { tabs.map { it.route } } + val currentSection = tabs.first { it.route == currentRoute } + JetsnackSurface( + modifier = modifier, color = color, - contentColor = contentColor + contentColor = contentColor, ) { - val springSpec = remember { - SpringSpec( - // Determined experimentally - stiffness = 800f, - dampingRatio = 0.8f - ) - } + val springSpec = spatialExpressiveSpring() JetsnackBottomNavLayout( selectedIndex = currentSection.ordinal, - itemCount = items.size, + itemCount = routes.size, indicator = { JetsnackBottomNavIndicator() }, animSpec = springSpec, - modifier = Modifier.navigationBarsPadding(left = false, right = false) + modifier = Modifier.navigationBarsPadding(), ) { - items.forEach { section -> + val configuration = LocalConfiguration.current + val currentLocale: Locale = + ConfigurationCompat.getLocales(configuration).get(0) ?: LocalLocale.current.platformLocale + + tabs.forEach { section -> val selected = section == currentSection val tint by animateColorAsState( if (selected) { JetsnackTheme.colors.iconInteractive } else { JetsnackTheme.colors.iconInteractiveInactive - } + }, + label = "tint", ) + val text = stringResource(section.title).uppercase(currentLocale) + JetsnackBottomNavigationItem( icon = { Icon( - imageVector = section.icon, + painter = painterResource(id = section.icon), tint = tint, - contentDescription = null + contentDescription = text, ) }, text = { Text( - text = stringResource(section.title).toUpperCase( - ConfigurationCompat.getLocales( - LocalConfiguration.current - ).get(0) - ), + text = text, color = tint, - style = MaterialTheme.typography.button, - maxLines = 1 + style = MaterialTheme.typography.labelLarge, + maxLines = 1, ) }, selected = selected, - onSelected = { onSectionSelected(section) }, + onSelected = { navigateToRoute(section.route) }, animSpec = springSpec, modifier = BottomNavigationItemPadding - .clip(BottomNavIndicatorShape) + .clip(BottomNavIndicatorShape), ) } } @@ -179,7 +237,7 @@ private fun JetsnackBottomNavLayout( animSpec: AnimationSpec, indicator: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { // Track how "selected" each item is [0, 1] val selectionFractions = remember(itemCount) { @@ -206,7 +264,7 @@ private fun JetsnackBottomNavLayout( content = { content() Box(Modifier.layoutId("indicator"), content = indicator) - } + }, ) { measurables, constraints -> check(itemCount == (measurables.size - 1)) // account for indicator @@ -223,26 +281,26 @@ private fun JetsnackBottomNavLayout( measurable.measure( constraints.copy( minWidth = width, - maxWidth = width - ) + maxWidth = width, + ), ) } val indicatorPlaceable = indicatorMeasurable.measure( constraints.copy( minWidth = selectedWidth, - maxWidth = selectedWidth - ) + maxWidth = selectedWidth, + ), ) layout( width = constraints.maxWidth, - height = itemPlaceables.maxByOrNull { it.height }?.height ?: 0 + height = itemPlaceables.maxByOrNull { it.height }?.height ?: 0, ) { val indicatorLeft = indicatorIndex.value * unselectedWidth - indicatorPlaceable.place(x = indicatorLeft.toInt(), y = 0) + indicatorPlaceable.placeRelative(x = indicatorLeft.toInt(), y = 0) var x = 0 itemPlaceables.forEach { placeable -> - placeable.place(x = x, y = 0) + placeable.placeRelative(x = x, y = 0) x += placeable.width } } @@ -256,45 +314,53 @@ fun JetsnackBottomNavigationItem( selected: Boolean, onSelected: () -> Unit, animSpec: AnimationSpec, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - Box( - modifier = modifier.selectable(selected = selected, onClick = onSelected), - contentAlignment = Alignment.Center - ) { - // Animate the icon/text positions within the item based on selection - val animationProgress by animateFloatAsState(if (selected) 1f else 0f, animSpec) - JetsnackBottomNavItemLayout( - icon = icon, - text = text, - animationProgress = animationProgress - ) - } + // Animate the icon/text positions within the item based on selection + val animationProgress by animateFloatAsState( + if (selected) 1f else 0f, animSpec, + label = "animation progress", + ) + JetsnackBottomNavItemLayout( + icon = icon, + text = text, + animationProgress = animationProgress, + modifier = modifier + .selectable(selected = selected, onClick = onSelected) + .wrapContentSize(), + ) } @Composable private fun JetsnackBottomNavItemLayout( icon: @Composable BoxScope.() -> Unit, text: @Composable BoxScope.() -> Unit, - @FloatRange(from = 0.0, to = 1.0) animationProgress: Float + @FloatRange(from = 0.0, to = 1.0) animationProgress: Float, + modifier: Modifier = Modifier, ) { Layout( + modifier = modifier, content = { - Box(Modifier.layoutId("icon"), content = icon) + Box( + modifier = Modifier + .layoutId("icon") + .padding(horizontal = TextIconSpacing), + content = icon, + ) val scale = lerp(0.6f, 1f, animationProgress) Box( modifier = Modifier .layoutId("text") - .padding(start = TextIconSpacing) + .padding(horizontal = TextIconSpacing) .graphicsLayer { alpha = animationProgress scaleX = scale scaleY = scale transformOrigin = BottomNavLabelTransformOrigin }, - content = text + content = text, ) - } + }, ) { measurables, constraints -> val iconPlaceable = measurables.first { it.layoutId == "icon" }.measure(constraints) val textPlaceable = measurables.first { it.layoutId == "text" }.measure(constraints) @@ -304,7 +370,7 @@ private fun JetsnackBottomNavItemLayout( iconPlaceable, constraints.maxWidth, constraints.maxHeight, - animationProgress + animationProgress, ) } } @@ -314,7 +380,7 @@ private fun MeasureScope.placeTextAndIcon( iconPlaceable: Placeable, width: Int, height: Int, - @FloatRange(from = 0.0, to = 1.0) animationProgress: Float + @FloatRange(from = 0.0, to = 1.0) animationProgress: Float, ): MeasureResult { val iconY = (height - iconPlaceable.height) / 2 val textY = (height - textPlaceable.height) / 2 @@ -324,9 +390,9 @@ private fun MeasureScope.placeTextAndIcon( val textX = iconX + iconPlaceable.width return layout(width, height) { - iconPlaceable.place(iconX.toInt(), iconY) + iconPlaceable.placeRelative(iconX.toInt(), iconY) if (animationProgress != 0f) { - textPlaceable.place(textX.toInt(), textY) + textPlaceable.placeRelative(textX.toInt(), textY) } } } @@ -335,40 +401,30 @@ private fun MeasureScope.placeTextAndIcon( private fun JetsnackBottomNavIndicator( strokeWidth: Dp = 2.dp, color: Color = JetsnackTheme.colors.iconInteractive, - shape: Shape = BottomNavIndicatorShape + shape: Shape = BottomNavIndicatorShape, ) { Spacer( modifier = Modifier .fillMaxSize() .then(BottomNavigationItemPadding) - .border(strokeWidth, color, shape) + .border(strokeWidth, color, shape), ) } -private val TextIconSpacing = 4.dp +private val TextIconSpacing = 2.dp private val BottomNavHeight = 56.dp private val BottomNavLabelTransformOrigin = TransformOrigin(0f, 0.5f) private val BottomNavIndicatorShape = RoundedCornerShape(percent = 50) private val BottomNavigationItemPadding = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) -private enum class HomeSections( - @StringRes val title: Int, - val icon: ImageVector -) { - Feed(R.string.home_feed, Icons.Outlined.Home), - Search(R.string.home_search, Icons.Outlined.Search), - Cart(R.string.home_cart, Icons.Outlined.ShoppingCart), - Profile(R.string.home_profile, Icons.Outlined.AccountCircle) -} - @Preview @Composable -private fun JsetsnackBottomNavPreview() { +private fun JetsnackBottomNavPreview() { JetsnackTheme { - JetsnackBottomNav( - currentSection = HomeSections.Feed, - onSectionSelected = { }, - items = HomeSections.values().toList() + JetsnackBottomBar( + tabs = HomeSections.entries.toTypedArray(), + currentRoute = "home/feed", + navigateToRoute = { }, ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Profile.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Profile.kt index 63aba7ab96..799fa28f3e 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Profile.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Profile.kt @@ -16,20 +16,64 @@ package com.example.jetsnack.ui.home +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +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.layout.wrapContentSize -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.example.jetsnack.R +import com.example.jetsnack.ui.theme.JetsnackTheme @Composable fun Profile(modifier: Modifier = Modifier) { - Text( - text = stringResource(R.string.home_profile), + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxSize() .wrapContentSize() - ) + .padding(24.dp), + ) { + Image( + painterResource(R.drawable.empty_state_search), + contentDescription = null, + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.work_in_progress), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.grab_beverage), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +fun ProfilePreview() { + JetsnackTheme { + Profile() + } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/Cart.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/Cart.kt index bb39ae40c7..6268546131 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/Cart.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/Cart.kt @@ -16,43 +16,56 @@ package com.example.jetsnack.ui.home.cart +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ChainStyle -import androidx.constraintlayout.compose.ConstraintLayout +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.example.jetsnack.R import com.example.jetsnack.model.OrderLine @@ -65,18 +78,20 @@ import com.example.jetsnack.ui.components.QuantitySelector import com.example.jetsnack.ui.components.SnackCollection import com.example.jetsnack.ui.components.SnackImage import com.example.jetsnack.ui.home.DestinationBar +import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring +import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring import com.example.jetsnack.ui.theme.AlphaNearOpaque import com.example.jetsnack.ui.theme.JetsnackTheme import com.example.jetsnack.ui.utils.formatPrice -import com.google.accompanist.insets.statusBarsHeight +import kotlin.math.roundToInt @Composable fun Cart( - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier + onSnackClick: (Long, String) -> Unit, + modifier: Modifier = Modifier, + viewModel: CartViewModel = viewModel(factory = CartViewModel.provideFactory()), ) { - val viewModel: CartViewModel = viewModel() - val orderLines by viewModel.orderLines.collectAsState() + val orderLines by viewModel.orderLines.collectAsStateWithLifecycle() val inspiredByCart = remember { SnackRepo.getInspiredByCart() } Cart( orderLines = orderLines, @@ -85,7 +100,7 @@ fun Cart( decreaseItemCount = viewModel::decreaseSnackCount, inspiredByCart = inspiredByCart, onSnackClick = onSnackClick, - modifier = modifier + modifier = modifier, ) } @@ -96,11 +111,11 @@ fun Cart( increaseItemCount: (Long) -> Unit, decreaseItemCount: (Long) -> Unit, inspiredByCart: SnackCollection, - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier + onSnackClick: (Long, String) -> Unit, + modifier: Modifier = Modifier, ) { JetsnackSurface(modifier = modifier.fillMaxSize()) { - Box { + Box(modifier = Modifier.fillMaxSize()) { CartContent( orderLines = orderLines, removeSnack = removeSnack, @@ -108,7 +123,7 @@ fun Cart( decreaseItemCount = decreaseItemCount, inspiredByCart = inspiredByCart, onSnackClick = onSnackClick, - modifier = Modifier.align(Alignment.TopCenter) + modifier = Modifier.align(Alignment.TopCenter), ) DestinationBar(modifier = Modifier.align(Alignment.TopCenter)) CheckoutBar(modifier = Modifier.align(Alignment.BottomCenter)) @@ -123,214 +138,280 @@ private fun CartContent( increaseItemCount: (Long) -> Unit, decreaseItemCount: (Long) -> Unit, inspiredByCart: SnackCollection, - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier + onSnackClick: (Long, String) -> Unit, + modifier: Modifier = Modifier, ) { val resources = LocalContext.current.resources val snackCountFormattedString = remember(orderLines.size, resources) { resources.getQuantityString( R.plurals.cart_order_count, - orderLines.size, orderLines.size + orderLines.size, orderLines.size, ) } + val itemAnimationSpecFade = nonSpatialExpressiveSpring() + val itemPlacementSpec = spatialExpressiveSpring() LazyColumn(modifier) { - item { - Spacer(Modifier.statusBarsHeight(additional = 56.dp)) + item(key = "title") { + Spacer( + Modifier.windowInsetsTopHeight( + WindowInsets.statusBars.add(WindowInsets(top = 56.dp)), + ), + ) Text( text = stringResource(R.string.cart_order_header, snackCountFormattedString), - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.brand, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .heightIn(min = 56.dp) .padding(horizontal = 24.dp, vertical = 4.dp) - .wrapContentHeight() + .wrapContentHeight(), ) } - items(orderLines) { orderLine -> - CartItem( - orderLine = orderLine, - removeSnack = removeSnack, - increaseItemCount = increaseItemCount, - decreaseItemCount = decreaseItemCount, - onSnackClick = onSnackClick - ) + items(orderLines, key = { it.snack.id }) { orderLine -> + SwipeDismissItem( + modifier = Modifier.animateItem( + fadeInSpec = itemAnimationSpecFade, + fadeOutSpec = itemAnimationSpecFade, + placementSpec = itemPlacementSpec, + ), + background = { progress -> + SwipeDismissItemBackground(progress) + }, + ) { + CartItem( + orderLine = orderLine, + removeSnack = removeSnack, + increaseItemCount = increaseItemCount, + decreaseItemCount = decreaseItemCount, + onSnackClick = onSnackClick, + ) + } } - item { + item("summary") { SummaryItem( - subtotal = orderLines.map { it.snack.price * it.count }.sum(), - shippingCosts = 369 + modifier = Modifier.animateItem( + fadeInSpec = itemAnimationSpecFade, + fadeOutSpec = itemAnimationSpecFade, + placementSpec = itemPlacementSpec, + ), + subtotal = orderLines.sumOf { it.snack.price * it.count }, + shippingCosts = 369, ) } - item { + item(key = "inspiredByCart") { SnackCollection( + modifier = Modifier.animateItem( + fadeInSpec = itemAnimationSpecFade, + fadeOutSpec = itemAnimationSpecFade, + placementSpec = itemPlacementSpec, + ), snackCollection = inspiredByCart, onSnackClick = onSnackClick, - highlight = false + highlight = false, ) Spacer(Modifier.height(56.dp)) } } } +@Composable +private fun SwipeDismissItemBackground(progress: Float) { + Column( + modifier = Modifier + .background(JetsnackTheme.colors.uiBackground) + .fillMaxWidth() + .fillMaxHeight(), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.Center, + ) { + // Set 4.dp padding only if progress is less than halfway + val padding: Dp by animateDpAsState( + if (progress < 0.5f) 4.dp else 0.dp, label = "padding", + ) + BoxWithConstraints( + Modifier + .fillMaxWidth(progress), + ) { + Surface( + modifier = Modifier + .padding(padding) + .fillMaxWidth() + .height(maxWidth) + .align(Alignment.Center), + shape = RoundedCornerShape(percent = ((1 - progress) * 100).roundToInt()), + color = JetsnackTheme.colors.error, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + // Icon must be visible while in this width range + if (progress in 0.125f..0.475f) { + // Icon alpha decreases as it is about to disappear + val iconAlpha: Float by animateFloatAsState( + if (progress > 0.4f) 0.5f else 1f, label = "icon alpha", + ) + + Icon( + painter = painterResource(id = R.drawable.ic_delete_forever), + modifier = Modifier + .size(32.dp) + .graphicsLayer(alpha = iconAlpha), + tint = JetsnackTheme.colors.uiBackground, + contentDescription = null, + ) + } + /*Text opacity increases as the text is supposed to appear in + the screen*/ + val textAlpha by animateFloatAsState( + if (progress > 0.5f) 1f else 0.5f, label = "text alpha", + ) + if (progress > 0.5f) { + Text( + text = stringResource(id = R.string.remove_item), + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.uiBackground, + textAlign = TextAlign.Center, + modifier = Modifier + .graphicsLayer( + alpha = textAlpha, + ), + ) + } + } + } + } + } +} + @Composable fun CartItem( orderLine: OrderLine, removeSnack: (Long) -> Unit, increaseItemCount: (Long) -> Unit, decreaseItemCount: (Long) -> Unit, - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier + onSnackClick: (Long, String) -> Unit, + modifier: Modifier = Modifier, ) { val snack = orderLine.snack - ConstraintLayout( + Column( modifier = modifier .fillMaxWidth() - .clickable { onSnackClick(snack.id) } - .padding(horizontal = 24.dp) + .clickable { onSnackClick(snack.id, "cart") } + .background(JetsnackTheme.colors.uiBackground) + .padding(horizontal = 24.dp), ) { - val (divider, image, name, tag, priceSpacer, price, remove, quantity) = createRefs() - createVerticalChain(name, tag, priceSpacer, price, chainStyle = ChainStyle.Packed) - SnackImage( - imageUrl = snack.imageUrl, - contentDescription = null, - modifier = Modifier - .size(100.dp) - .constrainAs(image) { - top.linkTo(parent.top, margin = 16.dp) - bottom.linkTo(parent.bottom, margin = 16.dp) - start.linkTo(parent.start) - } - ) - Text( - text = snack.name, - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier.constrainAs(name) { - linkTo( - start = image.end, - startMargin = 16.dp, - end = remove.start, - endMargin = 16.dp, - bias = 0f - ) - } - ) - IconButton( - onClick = { removeSnack(snack.id) }, - modifier = Modifier - .constrainAs(remove) { - top.linkTo(parent.top) - end.linkTo(parent.end) - } - .padding(top = 12.dp) + Row( + modifier = Modifier.fillMaxWidth(), ) { - Icon( - imageVector = Icons.Filled.Close, - tint = JetsnackTheme.colors.iconSecondary, - contentDescription = stringResource(R.string.label_remove) + SnackImage( + imageRes = snack.imageRes, + contentDescription = null, + modifier = Modifier + .padding(vertical = 16.dp) + .size(100.dp), ) - } - Text( - text = snack.tagline, - style = MaterialTheme.typography.body1, - color = JetsnackTheme.colors.textHelp, - modifier = Modifier.constrainAs(tag) { - linkTo( - start = image.end, - startMargin = 16.dp, - end = parent.end, - endMargin = 16.dp, - bias = 0f - ) - } - ) - Spacer( - Modifier - .height(8.dp) - .constrainAs(priceSpacer) { - linkTo(top = tag.bottom, bottom = price.top) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = snack.name, + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textSecondary, + modifier = Modifier + .weight(1f) + .padding(top = 16.dp, end = 16.dp), + ) + IconButton( + onClick = { removeSnack(snack.id) }, + modifier = Modifier.padding(top = 12.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + tint = JetsnackTheme.colors.iconSecondary, + contentDescription = stringResource(R.string.label_remove), + ) + } } - ) - Text( - text = formatPrice(snack.price), - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textPrimary, - modifier = Modifier.constrainAs(price) { - linkTo( - start = image.end, - end = quantity.start, - startMargin = 16.dp, - endMargin = 16.dp, - bias = 0f + Text( + text = snack.tagline, + style = MaterialTheme.typography.bodyLarge, + color = JetsnackTheme.colors.textHelp, + modifier = Modifier.padding(end = 16.dp), ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = formatPrice(snack.price), + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textPrimary, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + .alignBy(LastBaseline), + ) + QuantitySelector( + count = orderLine.count, + decreaseItemCount = { decreaseItemCount(snack.id) }, + increaseItemCount = { increaseItemCount(snack.id) }, + modifier = Modifier.alignBy(LastBaseline), + ) + } } - ) - QuantitySelector( - count = orderLine.count, - decreaseItemCount = { decreaseItemCount(snack.id) }, - increaseItemCount = { increaseItemCount(snack.id) }, - modifier = Modifier.constrainAs(quantity) { - baseline.linkTo(price.baseline) - end.linkTo(parent.end) - } - ) - JetsnackDivider( - Modifier.constrainAs(divider) { - linkTo(start = parent.start, end = parent.end) - top.linkTo(parent.bottom) - } - ) + } + JetsnackDivider() } } @Composable -fun SummaryItem( - subtotal: Long, - shippingCosts: Long, - modifier: Modifier = Modifier -) { +fun SummaryItem(subtotal: Long, shippingCosts: Long, modifier: Modifier = Modifier) { Column(modifier) { Text( text = stringResource(R.string.cart_summary_header), - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.brand, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(horizontal = 24.dp) .heightIn(min = 56.dp) - .wrapContentHeight() + .wrapContentHeight(), ) Row(modifier = Modifier.padding(horizontal = 24.dp)) { Text( text = stringResource(R.string.cart_subtotal_label), - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, modifier = Modifier .weight(1f) .wrapContentWidth(Alignment.Start) - .alignBy(LastBaseline) + .alignBy(LastBaseline), ) Text( text = formatPrice(subtotal), - style = MaterialTheme.typography.body1, - modifier = Modifier.alignBy(LastBaseline) + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.alignBy(LastBaseline), ) } Row(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) { Text( text = stringResource(R.string.cart_shipping_label), - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, modifier = Modifier .weight(1f) .wrapContentWidth(Alignment.Start) - .alignBy(LastBaseline) + .alignBy(LastBaseline), ) Text( text = formatPrice(shippingCosts), - style = MaterialTheme.typography.body1, - modifier = Modifier.alignBy(LastBaseline) + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.alignBy(LastBaseline), ) } Spacer(modifier = Modifier.height(8.dp)) @@ -338,17 +419,17 @@ fun SummaryItem( Row(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) { Text( text = stringResource(R.string.cart_total_label), - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, modifier = Modifier .weight(1f) .padding(end = 16.dp) .wrapContentWidth(Alignment.End) - .alignBy(LastBaseline) + .alignBy(LastBaseline), ) Text( text = formatPrice(subtotal + shippingCosts), - style = MaterialTheme.typography.subtitle1, - modifier = Modifier.alignBy(LastBaseline) + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.alignBy(LastBaseline), ) } JetsnackDivider() @@ -359,9 +440,10 @@ fun SummaryItem( private fun CheckoutBar(modifier: Modifier = Modifier) { Column( modifier.background( - JetsnackTheme.colors.uiBackground.copy(alpha = AlphaNearOpaque) - ) + JetsnackTheme.colors.uiBackground.copy(alpha = AlphaNearOpaque), + ), ) { + JetsnackDivider() Row { Spacer(Modifier.weight(1f)) @@ -370,19 +452,24 @@ private fun CheckoutBar(modifier: Modifier = Modifier) { shape = RectangleShape, modifier = Modifier .padding(horizontal = 12.dp, vertical = 8.dp) - .weight(1f) + .weight(1f), ) { Text( - text = stringResource(id = R.string.cart_checkout) + text = stringResource(id = R.string.cart_checkout), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Left, + maxLines = 1, ) } } } } -@Preview("Cart") +@Preview("default") +@Preview("dark theme", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable -fun CartPreview() { +private fun CartPreview() { JetsnackTheme { Cart( orderLines = SnackRepo.getCart(), @@ -390,22 +477,7 @@ fun CartPreview() { increaseItemCount = {}, decreaseItemCount = {}, inspiredByCart = SnackRepo.getInspiredByCart(), - onSnackClick = {} - ) - } -} - -@Preview("Cart • Dark Theme") -@Composable -fun CartDarkPreview() { - JetsnackTheme(darkTheme = true) { - Cart( - orderLines = SnackRepo.getCart(), - removeSnack = {}, - increaseItemCount = {}, - decreaseItemCount = {}, - inspiredByCart = SnackRepo.getInspiredByCart(), - onSnackClick = { } + onSnackClick = { _, _ -> }, ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/CartViewModel.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/CartViewModel.kt index 59d7492631..97e6b83526 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/CartViewModel.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/CartViewModel.kt @@ -17,8 +17,11 @@ package com.example.jetsnack.ui.home.cart import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.jetsnack.R import com.example.jetsnack.model.OrderLine import com.example.jetsnack.model.SnackRepo +import com.example.jetsnack.model.SnackbarManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,31 +30,44 @@ import kotlinx.coroutines.flow.StateFlow * * TODO: Move data to Repository so it can be displayed and changed consistently throughout the app. */ -class CartViewModel : ViewModel() { +class CartViewModel(private val snackbarManager: SnackbarManager, snackRepository: SnackRepo) : ViewModel() { + private val _orderLines: MutableStateFlow> = - MutableStateFlow(SnackRepo.getCart()) + MutableStateFlow(snackRepository.getCart()) val orderLines: StateFlow> get() = _orderLines - fun removeSnack(snackId: Long) { - _orderLines.value = _orderLines.value.filter { it.snack.id != snackId } - } + // Logic to show errors every few requests + private var requestCount = 0 + private fun shouldRandomlyFail(): Boolean = ++requestCount % 5 == 0 fun increaseSnackCount(snackId: Long) { - val currentCount = _orderLines.value.first { it.snack.id == snackId }.count - updateSnackCount(snackId, currentCount + 1) + if (!shouldRandomlyFail()) { + val currentCount = _orderLines.value.first { it.snack.id == snackId }.count + updateSnackCount(snackId, currentCount + 1) + } else { + snackbarManager.showMessage(R.string.cart_increase_error) + } } fun decreaseSnackCount(snackId: Long) { - val currentCount = _orderLines.value.first { it.snack.id == snackId }.count - if (currentCount == 1) { - // remove snack from cart - removeSnack(snackId) + if (!shouldRandomlyFail()) { + val currentCount = _orderLines.value.first { it.snack.id == snackId }.count + if (currentCount == 1) { + // remove snack from cart + removeSnack(snackId) + } else { + // update quantity in cart + updateSnackCount(snackId, currentCount - 1) + } } else { - // update quantity in cart - updateSnackCount(snackId, currentCount - 1) + snackbarManager.showMessage(R.string.cart_decrease_error) } } + fun removeSnack(snackId: Long) { + _orderLines.value = _orderLines.value.filter { it.snack.id != snackId } + } + private fun updateSnackCount(snackId: Long, count: Int) { _orderLines.value = _orderLines.value.map { if (it.snack.id == snackId) { @@ -61,4 +77,19 @@ class CartViewModel : ViewModel() { } } } + + /** + * Factory for CartViewModel that takes SnackbarManager as a dependency + */ + companion object { + fun provideFactory( + snackbarManager: SnackbarManager = SnackbarManager, + snackRepository: SnackRepo = SnackRepo, + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return CartViewModel(snackbarManager, snackRepository) as T + } + } + } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/SwipeDismissItem.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/SwipeDismissItem.kt new file mode 100644 index 0000000000..3e806fdc50 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/SwipeDismissItem.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.ui.home.cart + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Holds the Swipe to dismiss composable, its animation and the current state + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeDismissItem( + modifier: Modifier = Modifier, + enter: EnterTransition = expandVertically(), + exit: ExitTransition = shrinkVertically(), + background: @Composable (progress: Float) -> Unit, + content: @Composable (isDismissed: Boolean) -> Unit, +) { + // Hold the current state from the Swipe to Dismiss composable + val dismissState = rememberSwipeToDismissBoxState() + // Boolean value used for hiding the item if the current state is dismissed + val isDismissed = dismissState.currentValue == SwipeToDismissBoxValue.EndToStart + + AnimatedVisibility( + modifier = modifier, + visible = !isDismissed, + enter = enter, + exit = exit, + ) { + SwipeToDismissBox( + modifier = modifier, + state = dismissState, + enableDismissFromStartToEnd = false, + backgroundContent = { background(dismissState.progress) }, + content = { content(isDismissed) }, + ) + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt index c72c240fd3..dba32a0023 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt @@ -16,6 +16,7 @@ package com.example.jetsnack.ui.home.search +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -29,8 +30,8 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -41,6 +42,7 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp +import com.example.jetsnack.R import com.example.jetsnack.model.SearchCategory import com.example.jetsnack.model.SearchCategoryCollection import com.example.jetsnack.ui.components.SnackImage @@ -49,9 +51,7 @@ import com.example.jetsnack.ui.theme.JetsnackTheme import kotlin.math.max @Composable -fun SearchCategories( - categories: List -) { +fun SearchCategories(categories: List) { LazyColumn { itemsIndexed(categories) { index, collection -> SearchCategoryCollection(collection, index) @@ -61,31 +61,27 @@ fun SearchCategories( } @Composable -private fun SearchCategoryCollection( - collection: SearchCategoryCollection, - index: Int, - modifier: Modifier = Modifier -) { +private fun SearchCategoryCollection(collection: SearchCategoryCollection, index: Int, modifier: Modifier = Modifier) { Column(modifier) { Text( text = collection.name, - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.textPrimary, modifier = Modifier .heightIn(min = 56.dp) .padding(horizontal = 24.dp, vertical = 4.dp) - .wrapContentHeight() + .wrapContentHeight(), ) VerticalGrid(Modifier.padding(horizontal = 16.dp)) { val gradient = when (index % 2) { 0 -> JetsnackTheme.colors.gradient2_2 - else -> JetsnackTheme.colors.gradient3_2 + else -> JetsnackTheme.colors.gradient2_3 } collection.categories.forEach { category -> SearchCategory( category = category, gradient = gradient, - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(8.dp), ) } } @@ -98,11 +94,7 @@ private val CategoryShape = RoundedCornerShape(10.dp) private const val CategoryTextProportion = 0.55f @Composable -private fun SearchCategory( - category: SearchCategory, - gradient: List, - modifier: Modifier = Modifier -) { +private fun SearchCategory(category: SearchCategory, gradient: List, modifier: Modifier = Modifier) { Layout( modifier = modifier .aspectRatio(1.45f) @@ -113,18 +105,18 @@ private fun SearchCategory( content = { Text( text = category.name, - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, color = JetsnackTheme.colors.textSecondary, modifier = Modifier .padding(4.dp) - .padding(start = 8.dp) + .padding(start = 8.dp), ) SnackImage( - imageUrl = category.imageUrl, + imageRes = category.imageRes, contentDescription = null, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) - } + }, ) { measurables, constraints -> // Text given a set proportion of width (which is determined by the aspect ratio) val textWidth = (constraints.maxWidth * CategoryTextProportion).toInt() @@ -136,45 +128,33 @@ private fun SearchCategory( val imagePlaceable = measurables[1].measure(Constraints.fixed(imageSize, imageSize)) layout( width = constraints.maxWidth, - height = constraints.minHeight + height = constraints.minHeight, ) { - textPlaceable.place( + textPlaceable.placeRelative( x = 0, - y = (constraints.maxHeight - textPlaceable.height) / 2 // centered + y = (constraints.maxHeight - textPlaceable.height) / 2, // centered ) - imagePlaceable.place( + imagePlaceable.placeRelative( // image is placed to end of text i.e. will overflow to the end (but be clipped) x = textWidth, - y = (constraints.maxHeight - imagePlaceable.height) / 2 // centered + y = (constraints.maxHeight - imagePlaceable.height) / 2, // centered ) } } } -@Preview("Category") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable private fun SearchCategoryPreview() { JetsnackTheme { SearchCategory( category = SearchCategory( name = "Desserts", - imageUrl = "" - ), - gradient = JetsnackTheme.colors.gradient3_2 - ) - } -} - -@Preview("Category • Dark") -@Composable -private fun SearchCategoryDarkPreview() { - JetsnackTheme(darkTheme = true) { - SearchCategory( - category = SearchCategory( - name = "Desserts", - imageUrl = "" + imageRes = R.drawable.desserts, ), - gradient = JetsnackTheme.colors.gradient3_2 + gradient = JetsnackTheme.colors.gradient3_2, ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Results.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Results.kt index f6fd552169..05aebc129f 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Results.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Results.kt @@ -16,10 +16,13 @@ package com.example.jetsnack.ui.home.search +import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -30,11 +33,9 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -43,13 +44,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ChainStyle -import androidx.constraintlayout.compose.ConstraintLayout import com.example.jetsnack.R -import com.example.jetsnack.model.Filter import com.example.jetsnack.model.Snack import com.example.jetsnack.model.snacks -import com.example.jetsnack.ui.components.FilterBar import com.example.jetsnack.ui.components.JetsnackButton import com.example.jetsnack.ui.components.JetsnackDivider import com.example.jetsnack.ui.components.JetsnackSurface @@ -58,18 +55,13 @@ import com.example.jetsnack.ui.theme.JetsnackTheme import com.example.jetsnack.ui.utils.formatPrice @Composable -fun SearchResults( - searchResults: List, - filters: List, - onSnackClick: (Long) -> Unit -) { +fun SearchResults(searchResults: List, onSnackClick: (Long, String) -> Unit) { Column { - FilterBar(filters) Text( text = stringResource(R.string.search_count, searchResults.size), - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.textPrimary, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp) + modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), ) LazyColumn { itemsIndexed(searchResults) { index, snack -> @@ -80,167 +72,105 @@ fun SearchResults( } @Composable -private fun SearchResult( - snack: Snack, - onSnackClick: (Long) -> Unit, - showDivider: Boolean, - modifier: Modifier = Modifier -) { - ConstraintLayout( +private fun SearchResult(snack: Snack, onSnackClick: (Long, String) -> Unit, showDivider: Boolean, modifier: Modifier = Modifier) { + Box( modifier = modifier .fillMaxWidth() - .clickable { onSnackClick(snack.id) } - .padding(horizontal = 24.dp) + .clickable { onSnackClick(snack.id, "search") } + .padding(horizontal = 24.dp), ) { - val (divider, image, name, tag, priceSpacer, price, add) = createRefs() - createVerticalChain(name, tag, priceSpacer, price, chainStyle = ChainStyle.Packed) if (showDivider) { JetsnackDivider( - Modifier.constrainAs(divider) { - linkTo(start = parent.start, end = parent.end) - top.linkTo(parent.top) - } + Modifier.align(Alignment.TopCenter), ) } - SnackImage( - imageUrl = snack.imageUrl, - contentDescription = null, - modifier = Modifier - .size(100.dp) - .constrainAs(image) { - linkTo( - top = parent.top, - topMargin = 16.dp, - bottom = parent.bottom, - bottomMargin = 16.dp - ) - start.linkTo(parent.start) - } - ) - Text( - text = snack.name, - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier.constrainAs(name) { - linkTo( - start = image.end, - startMargin = 16.dp, - end = add.start, - endMargin = 16.dp, - bias = 0f + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 16.dp), + ) { + SnackImage( + imageRes = snack.imageRes, + contentDescription = null, + modifier = Modifier.size(100.dp), + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp, end = 16.dp), + ) { + Text( + text = snack.name, + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textSecondary, ) - } - ) - Text( - text = snack.tagline, - style = MaterialTheme.typography.body1, - color = JetsnackTheme.colors.textHelp, - modifier = Modifier.constrainAs(tag) { - linkTo( - start = image.end, - startMargin = 16.dp, - end = add.start, - endMargin = 16.dp, - bias = 0f + Text( + text = snack.tagline, + style = MaterialTheme.typography.bodyLarge, + color = JetsnackTheme.colors.textHelp, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = formatPrice(snack.price), + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textPrimary, ) } - ) - Spacer( - Modifier - .height(8.dp) - .constrainAs(priceSpacer) { - linkTo(top = tag.bottom, bottom = price.top) - } - ) - Text( - text = formatPrice(snack.price), - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textPrimary, - modifier = Modifier.constrainAs(price) { - linkTo( - start = image.end, - startMargin = 16.dp, - end = add.start, - endMargin = 16.dp, - bias = 0f + JetsnackButton( + onClick = { /* todo */ }, + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(36.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add), + contentDescription = stringResource(R.string.label_add), ) } - ) - JetsnackButton( - onClick = { /* todo */ }, - shape = CircleShape, - contentPadding = PaddingValues(0.dp), - modifier = Modifier - .size(36.dp) - .constrainAs(add) { - linkTo(top = parent.top, bottom = parent.bottom) - end.linkTo(parent.end) - } - ) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.label_add) - ) } } } @Composable -fun NoResults( - query: String, - modifier: Modifier = Modifier -) { +fun NoResults(query: String, modifier: Modifier = Modifier) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxSize() .wrapContentSize() - .padding(24.dp) + .padding(24.dp), ) { Image( painterResource(R.drawable.empty_state_search), - contentDescription = null + contentDescription = null, ) Spacer(Modifier.height(24.dp)) Text( text = stringResource(R.string.search_no_matches, query), - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(16.dp)) Text( text = stringResource(R.string.search_no_matches_retry), - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } -@Preview("Search Result") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable private fun SearchResultPreview() { JetsnackTheme { JetsnackSurface { SearchResult( snack = snacks[0], - onSnackClick = { }, - showDivider = false - ) - } - } -} - -@Preview("Search Result • Dark") -@Composable -private fun SearchResultDarkPreview() { - JetsnackTheme(darkTheme = true) { - JetsnackSurface { - SearchResult( - snack = snacks[0], - onSnackClick = { }, - showDivider = false + onSnackClick = { _, _ -> }, + showDivider = false, ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Search.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Search.kt index 5c5c84e005..33e84c51e7 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Search.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Search.kt @@ -16,6 +16,7 @@ package com.example.jetsnack.ui.home.search +import android.content.res.Configuration import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -25,18 +26,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowBack -import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -46,8 +45,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.isFocused import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview @@ -62,14 +61,9 @@ import com.example.jetsnack.model.SnackRepo import com.example.jetsnack.ui.components.JetsnackDivider import com.example.jetsnack.ui.components.JetsnackSurface import com.example.jetsnack.ui.theme.JetsnackTheme -import com.google.accompanist.insets.statusBarsPadding @Composable -fun Search( - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier, - state: SearchState = rememberSearchState() -) { +fun Search(onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier, state: SearchState = rememberSearchState()) { JetsnackSurface(modifier = modifier.fillMaxSize()) { Column { Spacer(modifier = Modifier.statusBarsPadding()) @@ -79,7 +73,7 @@ fun Search( searchFocused = state.focused, onSearchFocusChange = { state.focused = it }, onClearQuery = { state.query = TextFieldValue("") }, - searching = state.searching + searching = state.searching, ) JetsnackDivider() @@ -90,15 +84,19 @@ fun Search( } when (state.searchDisplay) { SearchDisplay.Categories -> SearchCategories(state.categories) + SearchDisplay.Suggestions -> SearchSuggestions( suggestions = state.suggestions, - onSuggestionSelect = { suggestion -> state.query = TextFieldValue(suggestion) } + onSuggestionSelect = { suggestion -> + state.query = TextFieldValue(suggestion) + }, ) + SearchDisplay.Results -> SearchResults( state.searchResults, - state.filters, - onSnackClick + onSnackClick, ) + SearchDisplay.NoResults -> NoResults(state.query.text) } } @@ -106,7 +104,10 @@ fun Search( } enum class SearchDisplay { - Categories, Suggestions, Results, NoResults + Categories, + Suggestions, + Results, + NoResults, } @Composable @@ -117,7 +118,7 @@ private fun rememberSearchState( categories: List = SearchRepo.getCategories(), suggestions: List = SearchRepo.getSuggestions(), filters: List = SnackRepo.getFilters(), - searchResults: List = emptyList() + searchResults: List = emptyList(), ): SearchState { return remember { SearchState( @@ -127,7 +128,7 @@ private fun rememberSearchState( categories = categories, suggestions = suggestions, filters = filters, - searchResults = searchResults + searchResults = searchResults, ) } } @@ -140,7 +141,7 @@ class SearchState( categories: List, suggestions: List, filters: List, - searchResults: List + searchResults: List, ) { var query by mutableStateOf(query) var focused by mutableStateOf(focused) @@ -166,7 +167,7 @@ private fun SearchBar( onSearchFocusChange: (Boolean) -> Unit, onClearQuery: () -> Unit, searching: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { JetsnackSurface( color = JetsnackTheme.colors.uiFloated, @@ -175,7 +176,7 @@ private fun SearchBar( modifier = modifier .fillMaxWidth() .height(56.dp) - .padding(horizontal = 24.dp, vertical = 8.dp) + .padding(horizontal = 24.dp, vertical = 8.dp), ) { Box(Modifier.fillMaxSize()) { if (query.text.isEmpty()) { @@ -185,14 +186,14 @@ private fun SearchBar( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxSize() - .wrapContentHeight() + .wrapContentHeight(), ) { if (searchFocused) { IconButton(onClick = onClearQuery) { Icon( - imageVector = Icons.Outlined.ArrowBack, + painter = painterResource(id = R.drawable.ic_arrow_back), tint = JetsnackTheme.colors.iconPrimary, - contentDescription = stringResource(R.string.label_back) + contentDescription = stringResource(R.string.label_back), ) } } @@ -203,14 +204,14 @@ private fun SearchBar( .weight(1f) .onFocusChanged { onSearchFocusChange(it.isFocused) - } + }, ) if (searching) { CircularProgressIndicator( color = JetsnackTheme.colors.iconPrimary, modifier = Modifier .padding(horizontal = 6.dp) - .size(36.dp) + .size(36.dp), ) } else { Spacer(Modifier.width(IconSize)) // balance arrow icon @@ -228,22 +229,24 @@ private fun SearchHint() { verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxSize() - .wrapContentSize() + .wrapContentSize(), ) { Icon( - imageVector = Icons.Outlined.Search, + painter = painterResource(id = R.drawable.ic_search), tint = JetsnackTheme.colors.textHelp, - contentDescription = stringResource(R.string.label_search) + contentDescription = stringResource(R.string.label_search), ) Spacer(Modifier.width(8.dp)) Text( text = stringResource(R.string.search_jetsnack), - color = JetsnackTheme.colors.textHelp + color = JetsnackTheme.colors.textHelp, ) } } -@Preview("Search Bar") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable private fun SearchBarPreview() { JetsnackTheme { @@ -254,24 +257,7 @@ private fun SearchBarPreview() { searchFocused = false, onSearchFocusChange = { }, onClearQuery = { }, - searching = false - ) - } - } -} - -@Preview("Search Bar • Dark") -@Composable -private fun SearchBarDarkPreview() { - JetsnackTheme(darkTheme = true) { - JetsnackSurface { - SearchBar( - query = TextFieldValue(""), - onQueryChange = { }, - searchFocused = false, - onSearchFocusChange = { }, - onClearQuery = { }, - searching = false + searching = false, ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Suggestions.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Suggestions.kt index bf96a1c2a9..62b9ff40c1 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Suggestions.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Suggestions.kt @@ -16,6 +16,7 @@ package com.example.jetsnack.ui.home.search +import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -25,8 +26,8 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,10 +39,7 @@ import com.example.jetsnack.ui.components.JetsnackSurface import com.example.jetsnack.ui.theme.JetsnackTheme @Composable -fun SearchSuggestions( - suggestions: List, - onSuggestionSelect: (String) -> Unit -) { +fun SearchSuggestions(suggestions: List, onSuggestionSelect: (String) -> Unit) { LazyColumn { suggestions.forEach { suggestionGroup -> item { @@ -51,7 +49,7 @@ fun SearchSuggestions( Suggestion( suggestion = suggestion, onSuggestionSelect = onSuggestionSelect, - modifier = Modifier.fillParentMaxWidth() + modifier = Modifier.fillParentMaxWidth(), ) } item { @@ -62,46 +60,41 @@ fun SearchSuggestions( } @Composable -private fun SuggestionHeader( - name: String, - modifier: Modifier = Modifier -) { +private fun SuggestionHeader(name: String, modifier: Modifier = Modifier) { Text( text = name, - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.textPrimary, modifier = modifier .heightIn(min = 56.dp) .padding(horizontal = 24.dp, vertical = 4.dp) - .wrapContentHeight() + .wrapContentHeight(), ) } @Composable -private fun Suggestion( - suggestion: String, - onSuggestionSelect: (String) -> Unit, - modifier: Modifier = Modifier -) { +private fun Suggestion(suggestion: String, onSuggestionSelect: (String) -> Unit, modifier: Modifier = Modifier) { Text( text = suggestion, - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, modifier = modifier .heightIn(min = 48.dp) .clickable { onSuggestionSelect(suggestion) } .padding(start = 24.dp) - .wrapContentSize(Alignment.CenterStart) + .wrapContentSize(Alignment.CenterStart), ) } -@Preview +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable fun PreviewSuggestions() { JetsnackTheme { JetsnackSurface { SearchSuggestions( suggestions = SearchRepo.getSuggestions(), - onSuggestionSelect = { } + onSuggestionSelect = { }, ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/JetsnackNavController.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/JetsnackNavController.kt new file mode 100644 index 0000000000..1d16f99818 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/JetsnackNavController.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination +import androidx.navigation.NavGraph +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController + +/** + * Destinations used in the [JetsnackApp]. + */ +object MainDestinations { + const val HOME_ROUTE = "home" + const val SNACK_DETAIL_ROUTE = "snack" + const val SNACK_ID_KEY = "snackId" + const val ORIGIN = "origin" +} + +/** + * Remembers and creates an instance of [JetsnackNavController] + */ +@Composable +fun rememberJetsnackNavController(navController: NavHostController = rememberNavController()): JetsnackNavController = + remember(navController) { + JetsnackNavController(navController) + } + +/** + * Responsible for holding UI Navigation logic. + */ +@Stable +class JetsnackNavController(val navController: NavHostController) { + + // ---------------------------------------------------------- + // Navigation state source of truth + // ---------------------------------------------------------- + + fun upPress() { + navController.navigateUp() + } + + fun navigateToBottomBarRoute(route: String) { + if (route != navController.currentDestination?.route) { + navController.navigate(route) { + launchSingleTop = true + restoreState = true + // Pop up backstack to the first destination and save state. This makes going back + // to the start destination when pressing back in any other bottom tab. + popUpTo(findStartDestination(navController.graph).id) { + saveState = true + } + } + } + } + + fun navigateToSnackDetail(snackId: Long, origin: String, from: NavBackStackEntry) { + // In order to discard duplicated navigation events, we check the Lifecycle + if (from.lifecycleIsResumed()) { + navController.navigate("${MainDestinations.SNACK_DETAIL_ROUTE}/$snackId?origin=$origin") + } + } +} + +/** + * If the lifecycle is not resumed it means this NavBackStackEntry already processed a nav event. + * + * This is used to de-duplicate navigation events. + */ +private fun NavBackStackEntry.lifecycleIsResumed() = this.lifecycle.currentState == Lifecycle.State.RESUMED + +private val NavGraph.startDestination: NavDestination? + get() = findNode(startDestinationId) + +/** + * Copied from similar function in NavigationUI.kt + * + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-ui/src/main/java/androidx/navigation/ui/NavigationUI.kt + */ +private tailrec fun findStartDestination(graph: NavDestination): NavDestination { + return if (graph is NavGraph) findStartDestination(graph.startDestination!!) else graph +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt index b36ad82dc1..32cf6dd0c5 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt @@ -14,10 +14,34 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalAnimationApi::class) + package com.example.jetsnack.ui.snackdetail +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,31 +51,46 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp @@ -60,8 +99,13 @@ import com.example.jetsnack.R import com.example.jetsnack.model.Snack import com.example.jetsnack.model.SnackCollection import com.example.jetsnack.model.SnackRepo +import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope +import com.example.jetsnack.ui.LocalSharedTransitionScope +import com.example.jetsnack.ui.SnackSharedElementKey +import com.example.jetsnack.ui.SnackSharedElementType import com.example.jetsnack.ui.components.JetsnackButton import com.example.jetsnack.ui.components.JetsnackDivider +import com.example.jetsnack.ui.components.JetsnackPreviewWrapper import com.example.jetsnack.ui.components.JetsnackSurface import com.example.jetsnack.ui.components.QuantitySelector import com.example.jetsnack.ui.components.SnackCollection @@ -69,8 +113,6 @@ import com.example.jetsnack.ui.components.SnackImage import com.example.jetsnack.ui.theme.JetsnackTheme import com.example.jetsnack.ui.theme.Neutral8 import com.example.jetsnack.ui.utils.formatPrice -import com.google.accompanist.insets.navigationBarsPadding -import com.google.accompanist.insets.statusBarsPadding import kotlin.math.max import kotlin.math.min @@ -85,126 +127,257 @@ private val ExpandedImageSize = 300.dp private val CollapsedImageSize = 150.dp private val HzPadding = Modifier.padding(horizontal = 24.dp) +fun spatialExpressiveSpring() = spring( + dampingRatio = 0.8f, + stiffness = 380f, +) + +fun nonSpatialExpressiveSpring() = spring( + dampingRatio = 1f, + stiffness = 1600f, +) + +val snackDetailBoundsTransform = BoundsTransform { _, _ -> + spatialExpressiveSpring() +} + @Composable -fun SnackDetail( - snackId: Long, - upPress: () -> Unit -) { +fun SnackDetail(snackId: Long, origin: String, upPress: () -> Unit) { val snack = remember(snackId) { SnackRepo.getSnack(snackId) } val related = remember(snackId) { SnackRepo.getRelated(snackId) } - - Box(Modifier.fillMaxSize()) { - val scroll = rememberScrollState(0) - Header() - Body(related, scroll) - Title(snack, scroll.value) - Image(snack.imageUrl, scroll.value) - Up(upPress) - CartBottomBar(modifier = Modifier.align(Alignment.BottomCenter)) + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No Scope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No Scope found") + val roundedCornerAnim by animatedVisibilityScope.transition + .animateDp(label = "rounded corner") { enterExit: EnterExitState -> + when (enterExit) { + EnterExitState.PreEnter -> 20.dp + EnterExitState.Visible -> 0.dp + EnterExitState.PostExit -> 20.dp + } + } + with(sharedTransitionScope) { + Box( + Modifier + .clip(RoundedCornerShape(roundedCornerAnim)) + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = origin, + type = SnackSharedElementType.Bounds, + ), + ), + animatedVisibilityScope, + clipInOverlayDuringTransition = + OverlayClip(RoundedCornerShape(roundedCornerAnim)), + boundsTransform = snackDetailBoundsTransform, + exit = fadeOut(nonSpatialExpressiveSpring()), + enter = fadeIn(nonSpatialExpressiveSpring()), + ) + .fillMaxSize() + .background(color = JetsnackTheme.colors.uiBackground), + ) { + val scroll = rememberScrollState(0) + Header(snack.id, origin = origin) + Body(related, scroll) + Title(snack, origin) { scroll.value } + Image(snackId, origin, snack.imageRes) { scroll.value } + Up(upPress) + CartBottomBar(modifier = Modifier.align(Alignment.BottomCenter)) + } } } @Composable -private fun Header() { - Spacer( - modifier = Modifier - .height(280.dp) - .fillMaxWidth() - .background(Brush.horizontalGradient(JetsnackTheme.colors.interactivePrimary)) - ) -} +private fun Header(snackId: Long, origin: String) { + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalArgumentException("No Scope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalArgumentException("No Scope found") -@Composable -private fun Up(upPress: () -> Unit) { - IconButton( - onClick = upPress, - modifier = Modifier - .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 10.dp) - .size(36.dp) - .background( - color = Neutral8.copy(alpha = 0.32f), - shape = CircleShape - ) - ) { - Icon( - imageVector = Icons.Outlined.ArrowBack, - tint = JetsnackTheme.colors.iconInteractive, - contentDescription = stringResource(R.string.label_back) + with(sharedTransitionScope) { + val brushColors = JetsnackTheme.colors.tornado1 + + val infiniteTransition = rememberInfiniteTransition(label = "background") + val targetOffset = with(LocalDensity.current) { + 1000.dp.toPx() + } + val offset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = targetOffset, + animationSpec = infiniteRepeatable( + tween(50000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "offset", + ) + Spacer( + modifier = Modifier + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snackId, + origin = origin, + type = SnackSharedElementType.Background, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(), + ) + .height(280.dp) + .fillMaxWidth() + .blur(40.dp) + .drawWithCache { + val brushSize = 400f + val brush = Brush.linearGradient( + colors = brushColors, + start = Offset(offset, offset), + end = Offset(offset + brushSize, offset + brushSize), + tileMode = TileMode.Mirror, + ) + onDrawBehind { + drawRect(brush) + } + }, ) } } @Composable -private fun Body( - related: List, - scroll: ScrollState -) { - Column { - Spacer( +private fun SharedTransitionScope.Up(upPress: () -> Unit) { + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalArgumentException("No Scope found") + with(animatedVisibilityScope) { + IconButton( + onClick = upPress, modifier = Modifier - .fillMaxWidth() + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 3f) .statusBarsPadding() - .height(MinTitleOffset) - ) - Column( - modifier = Modifier.verticalScroll(scroll) + .padding(horizontal = 16.dp, vertical = 10.dp) + .size(36.dp) + .animateEnterExit( + enter = scaleIn(tween(300, delayMillis = 300)), + exit = scaleOut(tween(20)), + ) + .background( + color = Neutral8.copy(alpha = 0.32f), + shape = CircleShape, + ), ) { - Spacer(Modifier.height(GradientScroll)) - JetsnackSurface(Modifier.fillMaxWidth()) { - Column { - Spacer(Modifier.height(ImageOverlap)) - Spacer(Modifier.height(TitleHeight)) - - Spacer(Modifier.height(16.dp)) - Text( - text = stringResource(R.string.detail_header), - style = MaterialTheme.typography.overline, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(R.string.detail_placeholder), - style = MaterialTheme.typography.body1, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + tint = JetsnackTheme.colors.iconInteractive, + contentDescription = stringResource(R.string.label_back), + ) + } + } +} - Spacer(Modifier.height(40.dp)) - Text( - text = stringResource(R.string.ingredients), - style = MaterialTheme.typography.overline, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(R.string.ingredients_list), - style = MaterialTheme.typography.body1, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) +@Composable +private fun Body(related: List, scroll: ScrollState) { + val sharedTransitionScope = + LocalSharedTransitionScope.current ?: throw IllegalStateException("No scope found") + with(sharedTransitionScope) { + Column(modifier = Modifier.skipToLookaheadSize()) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .height(MinTitleOffset), + ) - Spacer(Modifier.height(16.dp)) - JetsnackDivider() + Column( + modifier = Modifier.verticalScroll(scroll), + ) { + Spacer(Modifier.height(GradientScroll)) + Spacer(Modifier.height(ImageOverlap)) + JetsnackSurface( + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + Column { + Spacer(Modifier.height(TitleHeight)) + Text( + text = stringResource(R.string.detail_header), + style = MaterialTheme.typography.labelSmall, + color = JetsnackTheme.colors.textHelp, + modifier = HzPadding, + ) + Spacer(Modifier.height(16.dp)) + var seeMore by remember { mutableStateOf(true) } + with(sharedTransitionScope) { + Text( + text = stringResource(R.string.detail_placeholder), + style = MaterialTheme.typography.bodyLarge, + color = JetsnackTheme.colors.textHelp, + maxLines = if (seeMore) 5 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis, + modifier = HzPadding.skipToLookaheadSize(), - related.forEach { snackCollection -> - key(snackCollection.id) { - SnackCollection( - snackCollection = snackCollection, - onSnackClick = { }, - highlight = false ) } - } + val textButton = if (seeMore) { + stringResource(id = R.string.see_more) + } else { + stringResource(id = R.string.see_less) + } - Spacer( - modifier = Modifier - .padding(bottom = BottomBarHeight) - .navigationBarsPadding(left = false, right = false) - .height(8.dp) - ) + Text( + text = textButton, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + color = JetsnackTheme.colors.textLink, + modifier = Modifier + .heightIn(20.dp) + .fillMaxWidth() + .padding(top = 15.dp) + .clickable { + seeMore = !seeMore + } + .skipToLookaheadSize(), + ) + + Spacer(Modifier.height(40.dp)) + Text( + text = stringResource(R.string.ingredients), + style = MaterialTheme.typography.labelSmall, + color = JetsnackTheme.colors.textHelp, + modifier = HzPadding, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.ingredients_list), + style = MaterialTheme.typography.bodyLarge, + color = JetsnackTheme.colors.textHelp, + modifier = HzPadding, + ) + + Spacer(Modifier.height(16.dp)) + JetsnackDivider() + + related.forEach { snackCollection -> + key(snackCollection.id) { + SnackCollection( + snackCollection = snackCollection, + onSnackClick = { _, _ -> }, + highlight = false, + ) + } + } + + Spacer( + modifier = Modifier + .padding(bottom = BottomBarHeight) + .navigationBarsPadding() + .height(8.dp), + ) + } } } } @@ -212,77 +385,145 @@ private fun Body( } @Composable -private fun Title(snack: Snack, scroll: Int) { +private fun Title(snack: Snack, origin: String, scrollProvider: () -> Int) { val maxOffset = with(LocalDensity.current) { MaxTitleOffset.toPx() } val minOffset = with(LocalDensity.current) { MinTitleOffset.toPx() } - val offset = (maxOffset - scroll).coerceAtLeast(minOffset) - Column( - verticalArrangement = Arrangement.Bottom, - modifier = Modifier - .heightIn(min = TitleHeight) - .statusBarsPadding() - .graphicsLayer { translationY = offset } - .background(color = JetsnackTheme.colors.uiBackground) - ) { - Spacer(Modifier.height(16.dp)) - Text( - text = snack.name, - style = MaterialTheme.typography.h4, - color = JetsnackTheme.colors.textSecondary, - modifier = HzPadding - ) - Text( - text = snack.tagline, - style = MaterialTheme.typography.subtitle2, - fontSize = 20.sp, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) - Spacer(Modifier.height(4.dp)) - Text( - text = formatPrice(snack.price), - style = MaterialTheme.typography.h6, - color = JetsnackTheme.colors.textPrimary, - modifier = HzPadding - ) + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalArgumentException("No Scope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalArgumentException("No Scope found") - Spacer(Modifier.height(8.dp)) - JetsnackDivider() + with(sharedTransitionScope) { + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = TitleHeight) + .statusBarsPadding() + .offset { + val scroll = scrollProvider() + val offset = (maxOffset - scroll).coerceAtLeast(minOffset) + IntOffset(x = 0, y = offset.toInt()) + } + .background(JetsnackTheme.colors.uiBackground), + ) { + Spacer(Modifier.height(16.dp)) + Text( + text = snack.name, + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.headlineMedium, + color = JetsnackTheme.colors.textSecondary, + modifier = HzPadding + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = origin, + type = SnackSharedElementType.Title, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + ) + .wrapContentWidth(), + ) + Text( + text = snack.tagline, + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.titleSmall, + fontSize = 20.sp, + color = JetsnackTheme.colors.textHelp, + modifier = HzPadding + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = origin, + type = SnackSharedElementType.Tagline, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + ) + .wrapContentWidth(), + ) + Spacer(Modifier.height(4.dp)) + with(animatedVisibilityScope) { + Text( + text = formatPrice(snack.price), + style = MaterialTheme.typography.titleLarge, + color = JetsnackTheme.colors.textPrimary, + modifier = HzPadding + .animateEnterExit( + enter = fadeIn() + slideInVertically { -it / 3 }, + exit = fadeOut() + slideOutVertically { -it / 3 }, + ) + .skipToLookaheadSize(), + ) + } + Spacer(Modifier.height(8.dp)) + JetsnackDivider(modifier = Modifier) + } } } @Composable private fun Image( - imageUrl: String, - scroll: Int + snackId: Long, + origin: String, + @DrawableRes + imageRes: Int, + scrollProvider: () -> Int, ) { val collapseRange = with(LocalDensity.current) { (MaxTitleOffset - MinTitleOffset).toPx() } - val collapseFraction = (scroll / collapseRange).coerceIn(0f, 1f) + val collapseFractionProvider = { + (scrollProvider() / collapseRange).coerceIn(0f, 1f) + } CollapsingImageLayout( - collapseFraction = collapseFraction, - modifier = HzPadding.then(Modifier.statusBarsPadding()) + collapseFractionProvider = collapseFractionProvider, + modifier = HzPadding.statusBarsPadding(), ) { - SnackImage( - imageUrl = imageUrl, - contentDescription = null, - modifier = Modifier.fillMaxSize() - ) + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No sharedTransitionScope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No animatedVisibilityScope found") + + with(sharedTransitionScope) { + SnackImage( + imageRes = imageRes, + contentDescription = null, + modifier = Modifier + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snackId, + origin = origin, + type = SnackSharedElementType.Image, + ), + ), + animatedVisibilityScope = animatedVisibilityScope, + exit = fadeOut(), + enter = fadeIn(), + boundsTransform = snackDetailBoundsTransform, + ) + .fillMaxSize(), + + ) + } } } @Composable -private fun CollapsingImageLayout( - collapseFraction: Float, - modifier: Modifier = Modifier, - content: @Composable () -> Unit -) { +private fun CollapsingImageLayout(collapseFractionProvider: () -> Float, modifier: Modifier = Modifier, content: @Composable () -> Unit) { Layout( modifier = modifier, - content = content + content = content, ) { measurables, constraints -> check(measurables.size == 1) + val collapseFraction = collapseFractionProvider() + val imageMaxSize = min(ExpandedImageSize.roundToPx(), constraints.maxWidth) val imageMinSize = max(CollapsedImageSize.roundToPx(), constraints.minWidth) val imageWidth = lerp(imageMaxSize, imageMinSize, collapseFraction) @@ -292,68 +533,83 @@ private fun CollapsingImageLayout( val imageX = lerp( (constraints.maxWidth - imageWidth) / 2, // centered when expanded constraints.maxWidth - imageWidth, // right aligned when collapsed - collapseFraction + collapseFraction, ) layout( width = constraints.maxWidth, - height = imageY + imageWidth + height = imageY + imageWidth, ) { - imagePlaceable.place(imageX, imageY) + imagePlaceable.placeRelative(imageX, imageY) } } } @Composable private fun CartBottomBar(modifier: Modifier = Modifier) { - val (count, updateCount) = remember { mutableStateOf(1) } - JetsnackSurface(modifier) { - Column { - JetsnackDivider() - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .navigationBarsPadding(left = false, right = false) - .then(HzPadding) - .heightIn(min = BottomBarHeight) + val (count, updateCount) = remember { mutableIntStateOf(1) } + val sharedTransitionScope = + LocalSharedTransitionScope.current ?: throw IllegalStateException("No Shared scope") + val animatedVisibilityScope = + LocalNavAnimatedVisibilityScope.current ?: throw IllegalStateException("No Shared scope") + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + JetsnackSurface( + modifier = modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 4f) + .animateEnterExit( + enter = slideInVertically( + tween( + 300, + delayMillis = 300, + ), + ) { it } + fadeIn(tween(300, delayMillis = 300)), + exit = slideOutVertically(tween(50)) { it } + + fadeOut(tween(50)), + ), ) { - QuantitySelector( - count = count, - decreaseItemCount = { if (count > 0) updateCount(count - 1) }, - increaseItemCount = { updateCount(count + 1) } - ) - Spacer(Modifier.width(16.dp)) - JetsnackButton( - onClick = { /* todo */ }, - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(R.string.add_to_cart), - maxLines = 1 - ) + Column { + JetsnackDivider() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .navigationBarsPadding() + .then(HzPadding) + .heightIn(min = BottomBarHeight), + ) { + QuantitySelector( + count = count, + decreaseItemCount = { if (count > 0) updateCount(count - 1) }, + increaseItemCount = { updateCount(count + 1) }, + ) + Spacer(Modifier.width(16.dp)) + JetsnackButton( + onClick = { /* todo */ }, + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.add_to_cart), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } } } } } } -@Preview("Snack Detail") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable private fun SnackDetailPreview() { - JetsnackTheme { - SnackDetail( - snackId = 1L, - upPress = { } - ) - } -} - -@Preview("Snack Detail • Dark") -@Composable -private fun SnackDetailDarkPreview() { - JetsnackTheme(darkTheme = true) { + JetsnackPreviewWrapper { SnackDetail( snackId = 1L, - upPress = { } + origin = "details", + upPress = { }, ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Color.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Color.kt index 2c5b04cc29..eb99c98163 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Color.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Color.kt @@ -71,7 +71,7 @@ val Rose1 = Color(0xfffed6e2) val Rose0 = Color(0xfffff2f6) val Neutral8 = Color(0xff121212) -val Neutral7 = Color(0xdef000000) +val Neutral7 = Color(0xde000000) val Neutral6 = Color(0x99000000) val Neutral5 = Color(0x61000000) val Neutral4 = Color(0x1f000000) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Shape.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Shape.kt index 76d6b842d4..b8a05338e4 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Shape.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Shape.kt @@ -17,11 +17,11 @@ package com.example.jetsnack.ui.theme import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes +import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp val Shapes = Shapes( small = RoundedCornerShape(percent = 50), medium = RoundedCornerShape(20.dp), - large = RoundedCornerShape(0.dp) + large = RoundedCornerShape(0.dp), ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt index b7b61f9007..87f851d5ba 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt @@ -17,22 +17,17 @@ package com.example.jetsnack.ui.theme import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -import com.example.jetsnack.ui.utils.LocalSysUiController private val LightColorPalette = JetsnackColors( brand = Shadow5, + brandSecondary = Ocean3, uiBackground = Neutral0, uiBorder = Neutral4, uiFloated = FunctionalGrey, @@ -50,11 +45,14 @@ private val LightColorPalette = JetsnackColors( gradient3_2 = listOf(Rose2, Lavender3, Rose4), gradient2_1 = listOf(Shadow4, Shadow11), gradient2_2 = listOf(Ocean3, Shadow3), - isDark = false + gradient2_3 = listOf(Lavender3, Rose2), + tornado1 = listOf(Shadow4, Ocean3), + isDark = false, ) private val DarkColorPalette = JetsnackColors( brand = Shadow1, + brandSecondary = Ocean2, uiBackground = Neutral8, uiBorder = Neutral3, uiFloated = FunctionalDarkGrey, @@ -73,30 +71,22 @@ private val DarkColorPalette = JetsnackColors( gradient3_1 = listOf(Shadow9, Ocean7, Shadow5), gradient3_2 = listOf(Rose8, Lavender7, Rose11), gradient2_1 = listOf(Ocean3, Shadow3), - gradient2_2 = listOf(Ocean7, Shadow7), - isDark = true + gradient2_2 = listOf(Ocean4, Shadow2), + gradient2_3 = listOf(Lavender3, Rose3), + tornado1 = listOf(Shadow4, Ocean3), + isDark = true, ) @Composable -fun JetsnackTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { +fun JetsnackTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colors = if (darkTheme) DarkColorPalette else LightColorPalette - val sysUiController = LocalSysUiController.current - SideEffect { - sysUiController.setSystemBarsColor( - color = colors.uiBackground.copy(alpha = AlphaNearOpaque) - ) - } - ProvideJetsnackColors(colors) { MaterialTheme( - colors = debugColors(darkTheme), + colorScheme = debugColors(darkTheme), typography = Typography, shapes = Shapes, - content = content + content = content, ) } } @@ -110,122 +100,41 @@ object JetsnackTheme { /** * Jetsnack custom Color Palette */ -@Stable -class JetsnackColors( - gradient6_1: List, - gradient6_2: List, - gradient3_1: List, - gradient3_2: List, - gradient2_1: List, - gradient2_2: List, - brand: Color, - uiBackground: Color, - uiBorder: Color, - uiFloated: Color, - interactivePrimary: List = gradient2_1, - interactiveSecondary: List = gradient2_2, - interactiveMask: List = gradient6_1, - textPrimary: Color = brand, - textSecondary: Color, - textHelp: Color, - textInteractive: Color, - textLink: Color, - iconPrimary: Color = brand, - iconSecondary: Color, - iconInteractive: Color, - iconInteractiveInactive: Color, - error: Color, - notificationBadge: Color = error, - isDark: Boolean -) { - var gradient6_1 by mutableStateOf(gradient6_1) - private set - var gradient6_2 by mutableStateOf(gradient6_2) - private set - var gradient3_1 by mutableStateOf(gradient3_1) - private set - var gradient3_2 by mutableStateOf(gradient3_2) - private set - var gradient2_1 by mutableStateOf(gradient2_1) - private set - var gradient2_2 by mutableStateOf(gradient2_2) - private set - var brand by mutableStateOf(brand) - private set - var uiBackground by mutableStateOf(uiBackground) - private set - var uiBorder by mutableStateOf(uiBorder) - private set - var uiFloated by mutableStateOf(uiFloated) - private set - var interactivePrimary by mutableStateOf(interactivePrimary) - private set - var interactiveSecondary by mutableStateOf(interactiveSecondary) - private set - var interactiveMask by mutableStateOf(interactiveMask) - private set - var textPrimary by mutableStateOf(textPrimary) - private set - var textSecondary by mutableStateOf(textSecondary) - private set - var textHelp by mutableStateOf(textHelp) - private set - var textInteractive by mutableStateOf(textInteractive) - private set - var textLink by mutableStateOf(textLink) - private set - var iconPrimary by mutableStateOf(iconPrimary) - private set - var iconSecondary by mutableStateOf(iconSecondary) - private set - var iconInteractive by mutableStateOf(iconInteractive) - private set - var iconInteractiveInactive by mutableStateOf(iconInteractiveInactive) - private set - var error by mutableStateOf(error) - private set - var notificationBadge by mutableStateOf(notificationBadge) - private set - var isDark by mutableStateOf(isDark) - private set - - fun update(other: JetsnackColors) { - gradient6_1 = other.gradient6_1 - gradient6_2 = other.gradient6_2 - gradient3_1 = other.gradient3_1 - gradient3_2 = other.gradient3_2 - gradient2_1 = other.gradient2_1 - gradient2_2 = other.gradient2_2 - brand = other.brand - uiBackground = other.uiBackground - uiBorder = other.uiBorder - uiFloated = other.uiFloated - interactivePrimary = other.interactivePrimary - interactiveSecondary = other.interactiveSecondary - interactiveMask = other.interactiveMask - textPrimary = other.textPrimary - textSecondary = other.textSecondary - textHelp = other.textHelp - textInteractive = other.textInteractive - textLink = other.textLink - iconPrimary = other.iconPrimary - iconSecondary = other.iconSecondary - iconInteractive = other.iconInteractive - iconInteractiveInactive = other.iconInteractiveInactive - error = other.error - notificationBadge = other.notificationBadge - isDark = other.isDark - } -} +@Immutable +data class JetsnackColors( + val gradient6_1: List, + val gradient6_2: List, + val gradient3_1: List, + val gradient3_2: List, + val gradient2_1: List, + val gradient2_2: List, + val gradient2_3: List, + val brand: Color, + val brandSecondary: Color, + val uiBackground: Color, + val uiBorder: Color, + val uiFloated: Color, + val interactivePrimary: List = gradient2_1, + val interactiveSecondary: List = gradient2_2, + val interactiveMask: List = gradient6_1, + val textPrimary: Color = brand, + val textSecondary: Color, + val textHelp: Color, + val textInteractive: Color, + val textLink: Color, + val tornado1: List, + val iconPrimary: Color = brand, + val iconSecondary: Color, + val iconInteractive: Color, + val iconInteractiveInactive: Color, + val error: Color, + val notificationBadge: Color = error, + val isDark: Boolean, +) @Composable -fun ProvideJetsnackColors( - colors: JetsnackColors, - content: @Composable () -> Unit -) { - val colorPalette = remember { colors } - colorPalette.update(colors) - CompositionLocalProvider(LocalJetsnackColors provides colorPalette, content = content) +fun ProvideJetsnackColors(colors: JetsnackColors, content: @Composable () -> Unit) { + CompositionLocalProvider(LocalJetsnackColors provides colors, content = content) } private val LocalJetsnackColors = staticCompositionLocalOf { @@ -234,23 +143,55 @@ private val LocalJetsnackColors = staticCompositionLocalOf { /** * A Material [Colors] implementation which sets all colors to [debugColor] to discourage usage of - * [MaterialTheme.colors] in preference to [JetsnackTheme.colors]. + * [MaterialTheme.colorScheme] in preference to [JetsnackTheme.colors]. */ -fun debugColors( - darkTheme: Boolean, - debugColor: Color = Color.Magenta -) = Colors( +fun debugColors(darkTheme: Boolean, debugColor: Color = Color.Magenta) = ColorScheme( primary = debugColor, - primaryVariant = debugColor, - secondary = debugColor, - secondaryVariant = debugColor, - background = debugColor, - surface = debugColor, - error = debugColor, onPrimary = debugColor, + primaryContainer = debugColor, + onPrimaryContainer = debugColor, + inversePrimary = debugColor, + secondary = debugColor, onSecondary = debugColor, + secondaryContainer = debugColor, + onSecondaryContainer = debugColor, + tertiary = debugColor, + onTertiary = debugColor, + tertiaryContainer = debugColor, + onTertiaryContainer = debugColor, + background = debugColor, onBackground = debugColor, + surface = debugColor, onSurface = debugColor, + surfaceVariant = debugColor, + onSurfaceVariant = debugColor, + surfaceTint = debugColor, + inverseSurface = debugColor, + inverseOnSurface = debugColor, + error = debugColor, onError = debugColor, - isLight = !darkTheme + errorContainer = debugColor, + onErrorContainer = debugColor, + outline = debugColor, + outlineVariant = debugColor, + scrim = debugColor, + surfaceBright = debugColor, + surfaceDim = debugColor, + surfaceContainer = debugColor, + surfaceContainerHigh = debugColor, + surfaceContainerHighest = debugColor, + surfaceContainerLow = debugColor, + surfaceContainerLowest = debugColor, + primaryFixed = debugColor, + primaryFixedDim = debugColor, + onPrimaryFixed = debugColor, + onPrimaryFixedVariant = debugColor, + secondaryFixed = debugColor, + secondaryFixedDim = debugColor, + onSecondaryFixed = debugColor, + onSecondaryFixedVariant = debugColor, + tertiaryFixed = debugColor, + tertiaryFixedDim = debugColor, + onTertiaryFixed = debugColor, + onTertiaryFixedVariant = debugColor, ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Type.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Type.kt index 48a369dbb5..9f4045fa18 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Type.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Type.kt @@ -16,7 +16,7 @@ package com.example.jetsnack.ui.theme -import androidx.compose.material.Typography +import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -28,100 +28,100 @@ private val Montserrat = FontFamily( Font(R.font.montserrat_light, FontWeight.Light), Font(R.font.montserrat_regular, FontWeight.Normal), Font(R.font.montserrat_medium, FontWeight.Medium), - Font(R.font.montserrat_semibold, FontWeight.SemiBold) + Font(R.font.montserrat_semibold, FontWeight.SemiBold), ) private val Karla = FontFamily( Font(R.font.karla_regular, FontWeight.Normal), - Font(R.font.karla_bold, FontWeight.Bold) + Font(R.font.karla_bold, FontWeight.Bold), ) val Typography = Typography( - h1 = TextStyle( + displayLarge = TextStyle( fontFamily = Montserrat, fontSize = 96.sp, fontWeight = FontWeight.Light, lineHeight = 117.sp, - letterSpacing = (-1.5).sp + letterSpacing = (-1.5).sp, ), - h2 = TextStyle( + displayMedium = TextStyle( fontFamily = Montserrat, fontSize = 60.sp, fontWeight = FontWeight.Light, lineHeight = 73.sp, - letterSpacing = (-0.5).sp + letterSpacing = (-0.5).sp, ), - h3 = TextStyle( + displaySmall = TextStyle( fontFamily = Montserrat, fontSize = 48.sp, fontWeight = FontWeight.Normal, - lineHeight = 59.sp + lineHeight = 59.sp, ), - h4 = TextStyle( + headlineMedium = TextStyle( fontFamily = Montserrat, fontSize = 30.sp, fontWeight = FontWeight.SemiBold, - lineHeight = 37.sp + lineHeight = 37.sp, ), - h5 = TextStyle( + headlineSmall = TextStyle( fontFamily = Montserrat, fontSize = 24.sp, fontWeight = FontWeight.SemiBold, - lineHeight = 29.sp + lineHeight = 29.sp, ), - h6 = TextStyle( + titleLarge = TextStyle( fontFamily = Montserrat, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, - lineHeight = 24.sp + lineHeight = 24.sp, ), - subtitle1 = TextStyle( + titleMedium = TextStyle( fontFamily = Montserrat, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, lineHeight = 24.sp, - letterSpacing = 0.15.sp + letterSpacing = 0.15.sp, ), - subtitle2 = TextStyle( + titleSmall = TextStyle( fontFamily = Karla, fontSize = 14.sp, fontWeight = FontWeight.Bold, lineHeight = 24.sp, - letterSpacing = 0.1.sp + letterSpacing = 0.1.sp, ), - body1 = TextStyle( + bodyLarge = TextStyle( fontFamily = Karla, fontSize = 16.sp, fontWeight = FontWeight.Normal, lineHeight = 28.sp, - letterSpacing = 0.15.sp + letterSpacing = 0.15.sp, ), - body2 = TextStyle( + bodyMedium = TextStyle( fontFamily = Montserrat, fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp, - letterSpacing = 0.25.sp + letterSpacing = 0.25.sp, ), - button = TextStyle( + labelLarge = TextStyle( fontFamily = Montserrat, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, lineHeight = 16.sp, - letterSpacing = 1.25.sp + letterSpacing = 1.25.sp, ), - caption = TextStyle( + bodySmall = TextStyle( fontFamily = Karla, fontSize = 12.sp, fontWeight = FontWeight.Bold, lineHeight = 16.sp, - letterSpacing = 0.4.sp + letterSpacing = 0.4.sp, ), - overline = TextStyle( + labelSmall = TextStyle( fontFamily = Montserrat, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, lineHeight = 16.sp, - letterSpacing = 1.sp - ) + letterSpacing = 1.sp, + ), ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Currency.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Currency.kt index 10c243434d..7040e634f0 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Currency.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Currency.kt @@ -21,6 +21,6 @@ import java.text.NumberFormat fun formatPrice(price: Long): String { return NumberFormat.getCurrencyInstance().format( - BigDecimal(price).movePointLeft(2) + BigDecimal(price).movePointLeft(2), ) } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Navigation.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Navigation.kt deleted file mode 100644 index da34775800..0000000000 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Navigation.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetsnack.ui.utils - -import android.os.Parcelable -import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.runtime.saveable.listSaver -import androidx.compose.runtime.toMutableStateList - -/** - * A simple navigator which maintains a back stack. - */ -class Navigator private constructor( - initialBackStack: List, - backDispatcher: OnBackPressedDispatcher -) { - constructor( - initial: T, - backDispatcher: OnBackPressedDispatcher - ) : this(listOf(initial), backDispatcher) - - private val backStack = initialBackStack.toMutableStateList() - private val backCallback = object : OnBackPressedCallback(canGoBack()) { - override fun handleOnBackPressed() { - back() - } - }.also { callback -> - backDispatcher.addCallback(callback) - } - val current: T get() = backStack.last() - - fun back() { - backStack.removeAt(backStack.lastIndex) - backCallback.isEnabled = canGoBack() - } - - fun navigate(destination: T) { - backStack += destination - backCallback.isEnabled = canGoBack() - } - - private fun canGoBack(): Boolean = backStack.size > 1 - - companion object { - /** - * Serialize the back stack to save to instance state. - */ - fun saver(backDispatcher: OnBackPressedDispatcher) = - listSaver, T>( - save = { navigator -> navigator.backStack.toList() }, - restore = { backstack -> Navigator(backstack, backDispatcher) } - ) - } -} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/SystemUi.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/SystemUi.kt deleted file mode 100644 index 17b7d8708a..0000000000 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/SystemUi.kt +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetsnack.ui.utils - -import android.os.Build -import android.view.View -import android.view.Window -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.toArgb - -interface SystemUiController { - fun setStatusBarColor( - color: Color, - darkIcons: Boolean = color.luminance() > 0.5f, - transformColorForLightContent: (Color) -> Color = BlackScrimmed - ) - - fun setNavigationBarColor( - color: Color, - darkIcons: Boolean = color.luminance() > 0.5f, - transformColorForLightContent: (Color) -> Color = BlackScrimmed - ) - - fun setSystemBarsColor( - color: Color, - darkIcons: Boolean = color.luminance() > 0.5f, - transformColorForLightContent: (Color) -> Color = BlackScrimmed - ) -} - -fun SystemUiController(window: Window): SystemUiController { - return SystemUiControllerImpl(window) -} - -/** - * A helper class for setting the navigation and status bar colors for a [Window], gracefully - * degrading behavior based upon API level. - */ -private class SystemUiControllerImpl(private val window: Window) : SystemUiController { - - /** - * Set the status bar color. - * - * @param color The **desired** [Color] to set. This may require modification if running on an - * API level that only supports white status bar icons. - * @param darkIcons Whether dark status bar icons would be preferable. Only available on - * API 23+. - * @param transformColorForLightContent A lambda which will be invoked to transform [color] if - * dark icons were requested but are not available. Defaults to applying a black scrim. - */ - override fun setStatusBarColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) { - val statusBarColor = when { - darkIcons && Build.VERSION.SDK_INT < 23 -> transformColorForLightContent(color) - else -> color - } - window.statusBarColor = statusBarColor.toArgb() - - if (Build.VERSION.SDK_INT >= 23) { - @Suppress("DEPRECATION") - if (darkIcons) { - window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or - View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - } else { - window.decorView.systemUiVisibility = window.decorView.systemUiVisibility and - View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() - } - } - } - - /** - * Set the navigation bar color. - * - * @param color The **desired** [Color] to set. This may require modification if running on an - * API level that only supports white navigation bar icons. Additionally this will be ignored - * and [Color.Transparent] will be used on API 29+ where gesture navigation is preferred or the - * system UI automatically applies background protection in other navigation modes. - * @param darkIcons Whether dark navigation bar icons would be preferable. Only available on - * API 26+. - * @param transformColorForLightContent A lambda which will be invoked to transform [color] if - * dark icons were requested but are not available. Defaults to applying a black scrim. - */ - override fun setNavigationBarColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) { - val navBarColor = when { - Build.VERSION.SDK_INT >= 29 -> Color.Transparent // For gesture nav - darkIcons && Build.VERSION.SDK_INT < 26 -> transformColorForLightContent(color) - else -> color - } - window.navigationBarColor = navBarColor.toArgb() - - if (Build.VERSION.SDK_INT >= 26) { - @Suppress("DEPRECATION") - if (darkIcons) { - window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or - View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR - } else { - window.decorView.systemUiVisibility = window.decorView.systemUiVisibility and - View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() - } - } - } - - /** - * Set the status and navigation bars to [color]. - * - * @see setStatusBarColor - * @see setNavigationBarColor - */ - override fun setSystemBarsColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) { - setStatusBarColor(color, darkIcons, transformColorForLightContent) - setNavigationBarColor(color, darkIcons, transformColorForLightContent) - } -} - -/** - * An [androidx.compose.runtime.CompositionLocalProvider] holding the current [LocalSysUiController]. Defaults to a - * no-op controller; consumers should [provide][androidx.compose.runtime.CompositionLocalProvider] a real one. - */ -val LocalSysUiController = staticCompositionLocalOf { - FakeSystemUiController -} - -private val BlackScrim = Color(0f, 0f, 0f, 0.2f) // 20% opaque black -private val BlackScrimmed: (Color) -> Color = { original -> - BlackScrim.compositeOver(original) -} - -/** - * A fake implementation, useful as a default or used in Previews. - */ -private object FakeSystemUiController : SystemUiController { - override fun setStatusBarColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) = Unit - - override fun setNavigationBarColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) = Unit - - override fun setSystemBarsColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) = Unit -} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/ActionDemonstrationActivity.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/ActionDemonstrationActivity.kt new file mode 100644 index 0000000000..4578adde0e --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/ActionDemonstrationActivity.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget + +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.glance.action.ActionParameters + +internal val ActionSourceMessageKey = ActionParameters.Key("actionSourceMessageKey") + +/** + * Activity that is launched on clicks from different parts of sample widgets. Displays string + * describing source of the click. + */ +class ActionDemonstrationActivity : ComponentActivity() { + + override fun onResume() { + super.onResume() + setContent { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val source = intent.getStringExtra(ActionSourceMessageKey.name) ?: "Unknown" + Text("Launched from $source") + } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/RecentOrdersWidget.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/RecentOrdersWidget.kt new file mode 100644 index 0000000000..6e8ab809cd --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/RecentOrdersWidget.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.AppWidgetId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.provideContent +import com.example.jetsnack.R +import com.example.jetsnack.ui.MainActivity +import com.example.jetsnack.widget.data.RecentOrdersDataRepository +import com.example.jetsnack.widget.data.RecentOrdersDataRepository.Companion.getImageTextListDataRepo +import com.example.jetsnack.widget.layout.ImageTextListItemData +import com.example.jetsnack.widget.layout.ImageTextListLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class RecentOrdersWidget : GlanceAppWidget() { + // Unlike the "Single" size mode, using "Exact" allows us to have better control over rendering in + // different sizes. And, unlike the "Responsive" mode, it doesn't cause several views for each + // supported size to be held in the widget host's memory. + override val sizeMode: SizeMode = SizeMode.Exact + + override val previewSizeMode = SizeMode.Responsive( + setOf( + DpSize(256.dp, 115.dp), // 4x2 cell min size + DpSize(260.dp, 180.dp), // Medium width layout, height with header + ), + ) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val repo = getImageTextListDataRepo(id) + + val initialItems = withContext(Dispatchers.Default) { + repo.load(context) + } + + provideContent { + GlanceTheme { + val items by repo.data().collectAsState(initial = initialItems) + + key(LocalSize.current) { + WidgetContent( + items = items, + shoppingCartActionIntent = Intent( + context.applicationContext, + MainActivity::class.java, + ) + .setAction(Intent.ACTION_VIEW) + .setFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK, + ) + .setData("https://jetsnack.example.com/home/cart".toUri()), + ) + } + } + } + } + + @Composable + fun WidgetContent(items: List, shoppingCartActionIntent: Intent) { + val context = LocalContext.current + + ImageTextListLayout( + items = items, + title = context.getString(R.string.widget_title), + titleIconRes = R.drawable.widget_logo, + titleBarActionIconRes = R.drawable.shopping_cart, + titleBarActionIconContentDescription = context.getString( + R.string.shopping_cart_button_label, + ), + titleBarAction = actionStartActivity(shoppingCartActionIntent), + shoppingCartActionIntent = shoppingCartActionIntent, + ) + } + + override suspend fun providePreview(context: Context, widgetCategory: Int) { + val repo = RecentOrdersDataRepository() + val items = repo.load(context) + + provideContent { + GlanceTheme { + WidgetContent( + items = items, + shoppingCartActionIntent = Intent(), + ) + } + } + } +} + +class RecentOrdersWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget = RecentOrdersWidget() + + @SuppressLint("RestrictedApi") + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + appWidgetIds.forEach { + RecentOrdersDataRepository.cleanUp(AppWidgetId(it)) + } + super.onDeleted(context, appWidgetIds) + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/data/RecentOrdersDataRepository.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/data/RecentOrdersDataRepository.kt new file mode 100644 index 0000000000..3a1b6a7114 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/data/RecentOrdersDataRepository.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget.data + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat.getString +import androidx.glance.GlanceId +import com.example.jetsnack.R +import com.example.jetsnack.model.Snack +import com.example.jetsnack.model.snacks +import com.example.jetsnack.widget.layout.ImageTextListItemData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * A fake in-memory implementation of repository that produces list of [ImageTextListItemData] + */ +class RecentOrdersDataRepository { + private val data = MutableStateFlow(listOf()) + private var items = demoItems.take(MAX_ITEMS) + + /** + * Flow of [ImageTextListItemData]s that can be listened to during a Glance session. + */ + fun data(): Flow> = data + + /** + * Loads the list of [ImageTextListItemData]s. + */ + fun load(context: Context): List { + data.value = if (items.isNotEmpty()) { + processImagesAndBuildData(items, context) + } else { + listOf() + } + + return data.value + } + + private fun processImagesAndBuildData(items: List, context: Context): List { + val mappedItems = + items.map { item -> + return@map ImageTextListItemData( + key = item.key, + title = item.title, + supportingText = item.supportingText, + supportingImage = item.supportingImage, + trailingIconButton = R.drawable.add_shopping_cart, + trailingIconButtonContentDescription = + getString(context, R.string.add_to_cart_content_description), + snackKeys = item.snackKeys, + ) + } + + return mappedItems + } + + /** + * snackKey: This app adds snacks to the cart based on where the [Snack] is positioned in a list + */ + data class DemoDataItem( + val key: String, + val snackKeys: List, + val orderLine: List = snackKeys.map { snacks[it] }, + val title: String = orderLine[0].name, + val supportingText: String = orderLine.joinToString { it.name }, + @DrawableRes val supportingImage: Int = orderLine[0].imageRes, + @DrawableRes val trailingIconButton: Int? = null, + val trailingIconButtonContentDescription: String? = null, + ) + + companion object { + private const val MAX_ITEMS = 10 + + private val demoItems = listOf( + DemoDataItem( + key = "1", + snackKeys = listOf(0, 20), + ), + DemoDataItem( + key = "2", + snackKeys = listOf(1, 21), + ), + DemoDataItem( + key = "3", + snackKeys = listOf(2, 22), + ), + DemoDataItem( + key = "4", + snackKeys = listOf(3, 23), + ), + DemoDataItem( + key = "5", + snackKeys = listOf(4, 24), + ), + ) + + private val repositories = mutableMapOf() + + /** + * Returns the repository instance for the given widget represented by [glanceId]. + */ + fun getImageTextListDataRepo(glanceId: GlanceId): RecentOrdersDataRepository = synchronized(repositories) { + repositories.getOrPut(glanceId) { RecentOrdersDataRepository() } + } + + /** + * Cleans up local data associated with the provided [glanceId]. + */ + fun cleanUp(glanceId: GlanceId) { + synchronized(repositories) { + repositories.remove(glanceId) + } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/EmptyListContent.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/EmptyListContent.kt new file mode 100644 index 0000000000..c3cdb3f622 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/EmptyListContent.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget.layout + +import androidx.compose.runtime.Composable +import androidx.glance.LocalContext +import com.example.jetsnack.R +import com.example.jetsnack.widget.utils.ActionUtils + +/** + * Content to be displayed when there are no items in the list. To be displayed below the + * app-specific title bar in the [androidx.glance.appwidget.components.Scaffold] . + */ +@Composable +internal fun EmptyListContent() { + val context = LocalContext.current + + NoDataContent( + noDataText = context.getString(R.string.sample_no_data_text), + noDataIconRes = R.drawable.cupcake, + actionButtonText = context.getString(R.string.sample_add_button_text), + actionButtonIcon = R.drawable.cupcake, + actionButtonOnClick = ActionUtils.actionStartDemoActivity("on-click of add item button"), + ) +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ImageTextListLayout.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ImageTextListLayout.kt new file mode 100644 index 0000000000..cb42a9f6c9 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ImageTextListLayout.kt @@ -0,0 +1,524 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget.layout + +import android.content.Intent +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.components.CircleIconButton +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.ContentScale +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.example.jetsnack.R +import com.example.jetsnack.widget.layout.Dimensions.NUM_GRID_CELLS +import com.example.jetsnack.widget.layout.Dimensions.fillItemItemPadding +import com.example.jetsnack.widget.layout.Dimensions.filledItemCornerRadius +import com.example.jetsnack.widget.layout.Dimensions.imageCornerRadius +import com.example.jetsnack.widget.layout.Dimensions.verticalSpacing +import com.example.jetsnack.widget.layout.Dimensions.widgetPadding +import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Companion.shouldDisplayTrailingIconButton +import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Companion.showTitleBar +import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Large +import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Medium +import com.example.jetsnack.widget.layout.ImageTextListLayoutSize.Small +import com.example.jetsnack.widget.utils.ActionUtils.actionStartDemoActivity +import com.example.jetsnack.widget.utils.LargeWidgetPreview +import com.example.jetsnack.widget.utils.MediumWidgetPreview +import com.example.jetsnack.widget.utils.SmallWidgetPreview + +private val CART_ITEMS_KEY = ActionParameters.Key("CART_ITEMS_KEY") + +/** + * A layout focused on presenting a list of text with an image, and an optional icon button. The + * list is displayed in a [Scaffold] below an app-specific title bar. + * + * The layout drops not-so-important details as the size of the widget goes smaller. For instance, + * in the smallest size it drops the image and the icon button to allow displaying more items at + * at glance. In an medium size, displays image, text and optionally icon button in a single + * column list. In the large size, shows items in 2 column grid. + * + * In this sample layout, text is the primary focus of the widget and image acts as a supporting + * content. So, we prefer displaying horizontal [ListItem]s in most displays. + * + * The layout serves as an implementation suggestion, but should be customized to fit your + * product's needs. As you customize the layout, prefer supporting narrower as well as larger + * widget sizes. + * + * Note: When using images as bitmap, you should limit the number of items displayed in widgets. + * + * @param title the text to be displayed as title of the widget, e.g. name of your widget or app. + * @param titleIconRes a tintable icon that represents your app or brand, that can be displayed + * with the provided [title]. In this sample, we use icon from a drawable resource, but you should + * use an appropriate icon source for your use case. + * @param titleBarActionIconRes resource id of a tintable icon that can be displayed as + * an icon button within the title bar area of the widget. For example, a search icon. + * @param titleBarActionIconContentDescription description of the [titleBarActionIconRes] button + * to be used by the accessibility services. + * @param titleBarAction action to be performed on click of the [titleBarActionIconRes] button. + * @param items list of items to be displayed in the list; typically includes a short title for + * item, a supporting text and an image. + * + * @see [ImageTextListItemData] for accepted inputs. + */ +@Composable +fun ImageTextListLayout( + title: String, + @DrawableRes titleIconRes: Int, + @DrawableRes titleBarActionIconRes: Int, + titleBarActionIconContentDescription: String, + titleBarAction: Action, + items: List, + shoppingCartActionIntent: Intent, +) { + val imageTextListLayoutSize = ImageTextListLayoutSize.fromLocalSize() + + fun titleBar(): @Composable (() -> Unit) = { + TitleBar( + startIcon = ImageProvider(titleIconRes), + title = title.takeIf { imageTextListLayoutSize != Small } ?: "", + iconColor = GlanceTheme.colors.primary, + textColor = GlanceTheme.colors.onSurface, + actions = { + CircleIconButton( + imageProvider = ImageProvider(titleBarActionIconRes), + contentDescription = titleBarActionIconContentDescription, + contentColor = GlanceTheme.colors.secondary, + backgroundColor = null, // transparent + onClick = titleBarAction, + ) + }, + ) + } + + val scaffoldTopPadding = if (showTitleBar()) { + 0.dp + } else { + widgetPadding + } + + Scaffold( + backgroundColor = GlanceTheme.colors.widgetBackground, + modifier = GlanceModifier.padding( + top = scaffoldTopPadding, + bottom = widgetPadding, + ), + titleBar = if (showTitleBar()) { + titleBar() + } else { + null + }, + ) { + Content(items, shoppingCartActionIntent) + } +} + +@Composable +private fun Content(items: List, shoppingCartActionIntent: Intent) { + val displayTrailingIconIfPresent = shouldDisplayTrailingIconButton() + + if (items.isEmpty()) { + EmptyListContent() + } else { + when (ImageTextListLayoutSize.fromLocalSize()) { + Small -> { + ListView( + items = items, + displayImage = false, + displayTrailingIconIfPresent = displayTrailingIconIfPresent, + shoppingCartActionIntent = shoppingCartActionIntent, + ) + } + + Medium -> { + ListView( + items = items, + displayImage = true, + displayTrailingIconIfPresent = displayTrailingIconIfPresent, + shoppingCartActionIntent = shoppingCartActionIntent, + ) + } + + Large -> { + GridView( + items = items, + displayImage = true, + displayTrailingIconIfPresent = displayTrailingIconIfPresent, + shoppingCartActionIntent = shoppingCartActionIntent, + ) + } + } + } +} + +/** + * A vertical scrolling list displaying [FilledHorizontalListItem]s. Suitable for + * [ImageTextListLayoutSize.Small] and [ImageTextListLayoutSize.Large] sizes. + */ +@Composable +private fun ListView( + items: List, + displayImage: Boolean, + displayTrailingIconIfPresent: Boolean, + shoppingCartActionIntent: Intent, +) { + RoundedScrollingLazyColumn( + modifier = GlanceModifier.fillMaxSize(), + items = items, + verticalItemsSpacing = verticalSpacing, + itemContentProvider = { item -> + FilledHorizontalListItem( + item = item, + displayImage = displayImage, + displayTrailingIcon = displayTrailingIconIfPresent, + modifier = GlanceModifier.fillMaxSize(), + shoppingCartActionIntent = shoppingCartActionIntent, + ) + }, + ) +} + +/** + * A grid of [FilledHorizontalListItem]s suitable for [ImageTextListLayoutSize.Large] sizes. + * + * Supporting the grid display allows large screen users view more information at once. + */ +@Composable +private fun GridView( + items: List, + displayImage: Boolean, + displayTrailingIconIfPresent: Boolean, + shoppingCartActionIntent: Intent, +) { + RoundedScrollingLazyVerticalGrid( + gridCells = NUM_GRID_CELLS, + items = items, + cellSpacing = verticalSpacing, + itemContentProvider = { item -> + FilledHorizontalListItem( + item = item, + displayImage = displayImage, + displayTrailingIcon = displayTrailingIconIfPresent, + modifier = GlanceModifier.fillMaxSize(), + shoppingCartActionIntent = shoppingCartActionIntent, + ) + }, + modifier = GlanceModifier.fillMaxSize(), + ) +} + +/** + * Arranges the texts, the image and the icon button in a horizontal arrangement with a filled + * container. + */ +@Composable +private fun FilledHorizontalListItem( + item: ImageTextListItemData, + displayImage: Boolean, + displayTrailingIcon: Boolean, + modifier: GlanceModifier = GlanceModifier, + shoppingCartActionIntent: Intent, +) { + @Composable + fun TitleText() { + Text( + text = item.title, + maxLines = 2, + style = TextStyles.titleText, + ) + } + + @Composable + fun SupportingText() { + Text( + text = item.supportingText, + maxLines = 2, + style = TextStyles.supportingText, + ) + } + + @Composable + fun SupportingImage() { + item.supportingImage?.let { + Image( + provider = ImageProvider(item.supportingImage.toString().toInt()), + // contentDescription is null because in this sample, it serves merely as a visual; but if + // it gives additional info to user, you should set the appropriate content description. + contentDescription = null, + // Depending on your image content, you may want to select an appropriate ContentScale. + contentScale = ContentScale.Crop, + // Fixed size per UX spec + modifier = modifier.cornerRadius(imageCornerRadius).size(Dimensions.imageSize), + ) + } + } + + @Composable + fun IconButton(intent: Intent = shoppingCartActionIntent.clone() as Intent) { + if (item.trailingIconButton != null) { + // Using CircleIconButton allows us to keep the touch target 48x48 + CircleIconButton( + imageProvider = ImageProvider(item.trailingIconButton), + backgroundColor = null, // to show transparent background. + contentDescription = item.trailingIconButtonContentDescription, + onClick = actionStartActivity( + intent, + actionParametersOf( + CART_ITEMS_KEY to item.snackKeys.joinToString(separator = " "), + ), + ), + ) + } + } + + ListItem( + modifier = modifier + .padding(fillItemItemPadding) + .cornerRadius(filledItemCornerRadius) + .background(GlanceTheme.colors.secondaryContainer), + headlineContent = { TitleText() }, + supportingContent = { SupportingText() }, + leadingContent = if (displayImage) { + { SupportingImage() } + } else { + null + }, + trailingContent = if (displayTrailingIcon) { + { IconButton() } + } else { + null + }, + ) +} + +/** + * Holds data fields for a ImageTextListLayout. + * + * @param key a unique identifier for a specific item + * @param title a short text (1-3 words) representing the item + * @param supportingText a compact text (~50-55 characters) supporting the [title]; this allows + * keeping the title short and glanceable, as well as helps support smaller + * widget sizes. + * @param supportingImage an image to accompany the textual information displayed in [title] + * and the [supportingText]. + * @param trailingIconButton a tintable icon representing an action that can be performed in context + * of the item; e.g. bookmark icon, save icon, etc. + * @param trailingIconButtonContentDescription description of the [trailingIconButton] to be used by + * the accessibility services. + */ +data class ImageTextListItemData( + val key: String, + val title: String, + val supportingText: String, + @DrawableRes val supportingImage: Int? = null, + @DrawableRes val trailingIconButton: Int? = null, + val trailingIconButtonContentDescription: String? = null, + val snackKeys: List, +) + +/** + * Reference breakpoints for deciding on widget style to display e.g. list / grid etc. + * + * In this layout, only width breakpoints are used to scale the layout. + */ +private enum class ImageTextListLayoutSize(val maxWidth: Dp) { + // Single column vertical list without images or trailing button in this size. + Small(maxWidth = 260.dp), + + // Single column horizontal list with images and optional trailing button if exists. + Medium(maxWidth = 479.dp), + + // 2 Column Grid of horizontal list items. Images are always shown; trailing button is shown if + // it fits. + Large(maxWidth = 644.dp), + ; + + companion object { + /** + * Returns the corresponding [ImageTextListLayoutSize] to be considered for the current + * widget size. + */ + @Composable + fun fromLocalSize(): ImageTextListLayoutSize { + val width = LocalSize.current.width + + return if (width >= Medium.maxWidth) { + Large + } else if (width >= Small.maxWidth) { + Medium + } else { + Small + } + } + + @Composable + fun showTitleBar(): Boolean = LocalSize.current.height >= 180.dp + + /** + * Returns if icon button should be displayed across medium and large sizes based on + * predefined breakpoints. + */ + @Composable + fun shouldDisplayTrailingIconButton(): Boolean { + val widgetWidth = LocalSize.current.width + return (widgetWidth in 340.dp..479.dp || widgetWidth > 620.dp) + } + } +} + +private object TextStyles { + /** + * Style for the text displayed as title within each item. + */ + val titleText: TextStyle + @Composable get() = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = if (ImageTextListLayoutSize.fromLocalSize() == Small) { + 14.sp // M3 Title Small + } else { + 16.sp // M3 Title Medium + }, + color = GlanceTheme.colors.onSurface, + ) + + /** + * Style for the text displayed as supporting text within each item. + */ + val supportingText: TextStyle + @Composable get() = + TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, // M3 Label Medium + color = GlanceTheme.colors.secondary, + ) +} + +private object Dimensions { + /** Number of cells in the grid, when items are displayed as a grid. */ + const val NUM_GRID_CELLS = 2 + + /** Padding around the the widget content */ + val widgetPadding = 12.dp + + /** Corner radius for each filled list item. */ + val filledItemCornerRadius = 16.dp + + /** Padding applied to each item in the list. */ + val fillItemItemPadding = 12.dp + + /** Vertical Space between each item in the list. */ + val verticalSpacing = 4.dp + + /** Size in which images should be displayed in the list. */ + val imageSize: Dp = 68.dp + + /** Corner radius for image in each item. */ + val imageCornerRadius = 12.dp +} + +/** + * Preview sizes for the widget covering the width based breakpoints of the image grid layout. + * + * This allows verifying updates across multiple breakpoints. + */ +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 259, heightDp = 200) +@Preview(widthDp = 261, heightDp = 200) +@Preview(widthDp = 480, heightDp = 200) +@Preview(widthDp = 644, heightDp = 200) +private annotation class ImageTextListBreakpointPreviews + +/** + * Previews for the image grid layout with both title and supporting text below the image + * + * First we look at the previews at defined breakpoints, tweaking them as necessary. In addition, + * the previews at standard sizes allows us to quickly verify updates across min / max and common + * widget sizes without needing to run the app or manually place the widget. + */ +@ImageTextListBreakpointPreviews +@SmallWidgetPreview +@MediumWidgetPreview +@LargeWidgetPreview +@Composable +private fun ImageTextListLayoutPreview() { + val context = LocalContext.current + + ImageTextListLayout( + title = context.getString(R.string.widget_title), + titleIconRes = R.drawable.widget_logo, + titleBarActionIconRes = R.drawable.add_shopping_cart, + titleBarActionIconContentDescription = context.getString( + R.string.shopping_cart_button_label, + ), + titleBarAction = actionStartDemoActivity("Title bar action click"), + items = listOf( + ImageTextListItemData( + key = "1", + snackKeys = listOf(0, 20), + supportingText = "Some text", + title = "Some title", + ), + ImageTextListItemData( + key = "1", + snackKeys = listOf(1, 21), + supportingText = "Some text", + title = "Some title", + ), + ImageTextListItemData( + key = "1", + snackKeys = listOf(2, 22), + supportingText = "Some text", + title = "Some title", + ), + ImageTextListItemData( + key = "1", + snackKeys = listOf(3, 23), + supportingText = "Some text", + title = "Some title", + ), + ImageTextListItemData( + key = "1", + snackKeys = listOf(4, 24), + supportingText = "Some text", + title = "Some title", + ), + ), + shoppingCartActionIntent = Intent(), + ) +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ListItem.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ListItem.kt new file mode 100644 index 0000000000..b66086cf21 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/ListItem.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics + +/** + * Component to build an item within a list. Lists are a continuous, vertical indexes of text or + * images. + * + * Apply padding to the item using the [androidx.glance.layout.padding] modifier. + * + * @param headlineContent the [Composable] headline content of the list item; typically a 1-line + * prominent text within the list item + * @param modifier [GlanceModifier] to be applied to the list item + * @param contentSpacing spacing between the leading, center and trailing sections; default 16.dp + * @param supportingContent 1-2 line text to be displayed below the headline text of the list item + * @param leadingContent the leading content of the list item such as an image, icon, or a selection + * control such as checkbox, switch, or a radio button + * @param trailingContent the trailing meta text, icon, or a selection control such as switch, + * checkbox, or a radio button + * @param onClick an option action to be performed on click of the list item. + * @param itemContentDescription an optional text used by accessibility services to describe what + * this list item represents. If not provided, the non-clickable + * content within the list item will be read out. + */ +@Composable +fun ListItem( + headlineContent: @Composable (() -> Unit), + modifier: GlanceModifier = GlanceModifier, + contentSpacing: Dp = 16.dp, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + onClick: Action? = null, + itemContentDescription: String? = null, +) { + val listItemModifier = if (itemContentDescription != null) { + modifier.semantics { contentDescription = itemContentDescription } + } else { + modifier + } + + Row( + modifier = listItemModifier.maybeClickable(onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + // Leading + leadingContent?.let { + it() + Spacer(modifier = GlanceModifier.width(contentSpacing)) + } + // Center + Column( + modifier = GlanceModifier.defaultWeight(), + verticalAlignment = Alignment.CenterVertically, + ) { + headlineContent() + supportingContent?.let { it() } + } + // Trailing + trailingContent?.let { + Spacer(modifier = GlanceModifier.width(contentSpacing)) + it() + } + } +} + +private fun GlanceModifier.maybeClickable(action: Action?): GlanceModifier = if (action != null) { + this.clickable(action) +} else { + this +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/NoDataContent.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/NoDataContent.kt new file mode 100644 index 0000000000..2bbe4a4f9e --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/NoDataContent.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.appwidget.components.FilledButton +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.height +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle + +/** + * Component for a view that can be shown when the app has no data to present. + * + * The content should be displayed in a [androidx.glance.appwidget.components.Scaffold] below an app-specific + * title bar. + * + * @param noDataText text indicating that there is no data available to display. + * @param noDataIconRes a tintable icon indicating there is no data available to display; usually + * a crossed-out icon representing the data that is empty; e.g. a crossed out + * message icon if there were no messages. + * @param actionButtonText text for the button that performs specific operation when there is no + * data; e.g. sign-in button, add button, etc. + * @param actionButtonIcon a leading icon to be displayed within the action button. + * @param actionButtonOnClick action to be performed on click of the action button. + */ +@Composable +fun NoDataContent(noDataIconRes: Int, noDataText: String, actionButtonText: String, actionButtonIcon: Int, actionButtonOnClick: Action) { + @Composable + fun showIcon() = LocalSize.current.height >= 180.dp + + Column( + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = GlanceModifier.fillMaxSize(), + ) { + if (showIcon()) { + Image( + provider = ImageProvider(noDataIconRes), + colorFilter = ColorFilter.tint(GlanceTheme.colors.secondary), + contentDescription = null, // only decorative + ) + Spacer(modifier = GlanceModifier.height(8.dp)) + } + Text( + text = noDataText, + style = TextStyle( + fontWeight = FontWeight.Medium, + color = GlanceTheme.colors.onSurface, + fontSize = 16.sp, // M3 - title/medium + ), + ) + Spacer(modifier = GlanceModifier.height(8.dp)) + FilledButton( + text = actionButtonText, + icon = ImageProvider(actionButtonIcon), + onClick = actionButtonOnClick, + ) + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyColumn.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyColumn.kt new file mode 100644 index 0000000000..557abb417e --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyColumn.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.LazyListScope +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.lazy.itemsIndexed +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height + +/** + * A variant of [LazyColumn] that clips its scrolling content to a rounded rectangle. + * + * @param modifier the modifier to apply to this layout + * @param horizontalAlignment the horizontal alignment applied to the items. + * @param content a block which describes the content. Inside this block you can use methods like + * [LazyListScope.item] to add a single item or [LazyListScope.items] to add a list of items. If the + * item has more than one top-level child, they will be automatically wrapped in a Box. + * @see LazyColumn + */ +@Composable +fun RoundedScrollingLazyColumn( + modifier: GlanceModifier = GlanceModifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: LazyListScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .cornerRadius(16.dp), // to present a rounded scrolling experience + ) { + LazyColumn( + horizontalAlignment = horizontalAlignment, + content = content, + ) + } +} + +/** + * * A variant of [LazyColumn] that clips its scrolling content to a rounded rectangle and spaces + * out each item in the list with a default 8.dp spacing + * + * @param items the list of data items to be displayed in the list + * @param itemContentProvider a lambda function that provides item content without any spacing + * @param modifier the modifier to apply to this layout + * @param horizontalAlignment the horizontal alignment applied to the items. + * @param verticalItemsSpacing vertical spacing between items + * @see LazyColumn + */ +@Composable +fun RoundedScrollingLazyColumn( + items: List, + itemContentProvider: @Composable (item: T) -> Unit, + modifier: GlanceModifier = GlanceModifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalItemsSpacing: Dp = 4.dp, +) { + val lastIndex = items.size - 1 + + RoundedScrollingLazyColumn(modifier, horizontalAlignment) { + itemsIndexed(items) { index, item -> + Column(modifier = GlanceModifier.fillMaxWidth()) { + itemContentProvider(item) + if (index != lastIndex) { + Spacer(modifier = GlanceModifier.height(verticalItemsSpacing)) + } + } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyVerticalGrid.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyVerticalGrid.kt new file mode 100644 index 0000000000..d6092961dc --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/layout/RoundedScrollingLazyVerticalGrid.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.GridCells +import androidx.glance.appwidget.lazy.LazyVerticalGrid +import androidx.glance.appwidget.lazy.LazyVerticalGridScope +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.lazy.itemsIndexed +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import kotlin.math.ceil + +/** + * A variant of [LazyVerticalGrid] that clips its scrolling content to a rounded rectangle. + * + * @param gridCells the number of columns in the grid. + * @param modifier the modifier to apply to this layout + * @param horizontalAlignment the horizontal alignment applied to the items. + * @param content a block which describes the content. Inside this block you can use methods like + * [LazyVerticalGridScope.item] to add a single item or [LazyVerticalGridScope.items] to add a list + * of items. If the item has more than one top-level child, they will be automatically wrapped in a + * Box. + * @see LazyVerticalGrid + */ +@Composable +fun RoundedScrollingLazyVerticalGrid( + gridCells: GridCells, + modifier: GlanceModifier = GlanceModifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: LazyVerticalGridScope.() -> Unit, +) { + Box( + modifier = GlanceModifier + .cornerRadius(16.dp) // to present a rounded scrolling experience + .then(modifier), + ) { + LazyVerticalGrid( + gridCells = gridCells, + horizontalAlignment = horizontalAlignment, + content = content, + ) + } +} + +/** + * A variant of [LazyVerticalGrid] that clips its scrolling content to a rounded rectangle and + * spaces out each item in the grid with a default 8.dp spacing. + * + * @param gridCells number of columns in the grid + * @param items the list of data items to be displayed in the list + * @param itemContentProvider a lambda function that provides item content without any spacing + * @param modifier the modifier to apply to this layout + * @param horizontalAlignment the horizontal alignment applied to the items. + * @param cellSpacing horizontal and vertical spacing between cells + * @see LazyVerticalGrid + */ +@Composable +fun RoundedScrollingLazyVerticalGrid( + gridCells: Int, + items: List, + itemContentProvider: @Composable (item: T) -> Unit, + modifier: GlanceModifier = GlanceModifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + cellSpacing: Dp = 12.dp, +) { + val numRows = ceil(items.size.toDouble() / gridCells).toInt() + + // Cell spacing is achieved by allocating equal amount of padding to each cell. Cells on edge + // apply it completely to inner sides, while cells not on edge apply it evenly on sides. + val perCellHorizontalPadding = (cellSpacing * (gridCells - 1)) / gridCells + val perCellVerticalPadding = (cellSpacing * (numRows - 1)) / numRows + + RoundedScrollingLazyVerticalGrid( + gridCells = GridCells.Fixed(gridCells), + horizontalAlignment = horizontalAlignment, + modifier = modifier, + ) { + itemsIndexed(items) { index, item -> + val row = index / gridCells + val column = index % gridCells + + val cellTopPadding = when (row) { + 0 -> 0.dp + numRows - 1 -> perCellVerticalPadding + else -> perCellVerticalPadding / 2 + } + + val cellBottomPadding = when (row) { + 0 -> perCellVerticalPadding + numRows - 1 -> 0.dp + else -> perCellVerticalPadding / 2 + } + + val cellStartPadding = when (column) { + 0 -> 0.dp + gridCells - 1 -> perCellHorizontalPadding + else -> perCellHorizontalPadding / 2 + } + + val cellEndPadding = when (column) { + 0 -> perCellHorizontalPadding + gridCells - 1 -> 0.dp + else -> perCellHorizontalPadding / 2 + } + + Box( + modifier = modifier + .fillMaxSize() + .padding( + start = cellStartPadding, + end = cellEndPadding, + top = cellTopPadding, + bottom = cellBottomPadding, + ), + ) { + itemContentProvider(item) + } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/ActionUtils.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/ActionUtils.kt new file mode 100644 index 0000000000..a4f0daea45 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/ActionUtils.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget.utils + +import androidx.compose.runtime.Composable +import androidx.glance.action.Action +import androidx.glance.action.actionParametersOf +import androidx.glance.action.actionStartActivity +import com.example.jetsnack.widget.ActionDemonstrationActivity +import com.example.jetsnack.widget.ActionSourceMessageKey + +/** + * Utility functions for creating [Action]s. + */ +object ActionUtils { + /** + * [Action] for launching the [ActionDemonstrationActivity] with the given message. + */ + @Composable + fun actionStartDemoActivity(message: String) = actionStartActivity( + actionParametersOf( + ActionSourceMessageKey to message, + ), + ) +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/PreviewAnnotations.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/PreviewAnnotations.kt new file mode 100644 index 0000000000..3ad68ab8c6 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/widget/utils/PreviewAnnotations.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2023-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.widget.utils + +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview + +/** + * Previews for 2x2 sized widgets in handheld and tablets. + * + * https://developer.android.com/design/ui/mobile/guides/widgets/sizing + */ +@OptIn(ExperimentalGlancePreviewApi::class) +// Reference devices +@Preview(widthDp = 172, heightDp = 234) // pixel 6a - smaller phone (2x2 in 4x5 grid - portrait) +@Preview(widthDp = 172, heightDp = 224) // pixel 7 pro - larger phone (2x2 in 4x5 grid - portrait) +@Preview(widthDp = 304, heightDp = 229) // Pixel tablet (2x2 in 6x5 grid - landscape) +// Min / max sizes +@Preview(widthDp = 109, heightDp = 115) // min handheld +@Preview(widthDp = 306, heightDp = 276) // max handheld +@Preview(widthDp = 180, heightDp = 184) // min tablet +@Preview(widthDp = 304, heightDp = 304) // max tablet +annotation class SmallWidgetPreview + +/** + * Previews for 4x2 handheld and 3x2 tablet widgets. + * + * https://developer.android.com/design/ui/mobile/guides/widgets/sizing + */ +@OptIn(ExperimentalGlancePreviewApi::class) +// Reference devices +@Preview(widthDp = 360, heightDp = 234) // pixel 6a - smaller phone (4x2 in 4x5 grid - portrait) +@Preview(widthDp = 360, heightDp = 224) // pixel 7 pro - larger phone (4x2 in 4x5 grid - portrait) +@Preview(widthDp = 488, heightDp = 229) // Pixel tablet (3x2 in 6x5 grid - landscape) +// Min / max sizes +@Preview(widthDp = 245, heightDp = 115) // min handheld +@Preview(widthDp = 624, heightDp = 276) // max handheld +@Preview(widthDp = 298, heightDp = 184) // min tablet +@Preview(widthDp = 488, heightDp = 304) // max tablet +annotation class MediumWidgetPreview + +/** + * Previews for 4x3 handheld and 3x3 tablet widgets. + * + * https://developer.android.com/design/ui/mobile/guides/widgets/sizing + */ +@OptIn(ExperimentalGlancePreviewApi::class) +// Reference devices +@Preview(widthDp = 360, heightDp = 359) // pixel 6a - smaller phone (4x3 in 4x5 grid - portrait) +@Preview(widthDp = 360, heightDp = 344) // pixel 7 pro - larger phone (4x3 in 4x5 grid - portrait) +@Preview(widthDp = 488, heightDp = 352) // Pixel tablet (3x3 in 6x5 grid - landscape) +// Min / max sizes +@Preview(widthDp = 245, heightDp = 185) // min handheld +@Preview(widthDp = 624, heightDp = 422) // max handheld +@Preview(widthDp = 298, heightDp = 424) // min tablet +@Preview(widthDp = 488, heightDp = 672) // max tablet +annotation class LargeWidgetPreview diff --git a/Jetsnack/app/src/main/res/drawable-night/empty_state_search.xml b/Jetsnack/app/src/main/res/drawable-night/empty_state_search.xml index 1cd280e07a..2ed8ee6032 100644 --- a/Jetsnack/app/src/main/res/drawable-night/empty_state_search.xml +++ b/Jetsnack/app/src/main/res/drawable-night/empty_state_search.xml @@ -1,5 +1,4 @@ - - + + + + + + diff --git a/Jetsnack/app/src/main/res/drawable/backward_compatible_widget_preview.png b/Jetsnack/app/src/main/res/drawable/backward_compatible_widget_preview.png new file mode 100644 index 0000000000..06d1f88a06 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable/backward_compatible_widget_preview.png differ diff --git a/Jetsnack/app/src/main/res/drawable/empty_state_search.xml b/Jetsnack/app/src/main/res/drawable/empty_state_search.xml index e2fa0cdac8..94095221cf 100644 --- a/Jetsnack/app/src/main/res/drawable/empty_state_search.xml +++ b/Jetsnack/app/src/main/res/drawable/empty_state_search.xml @@ -1,5 +1,4 @@ - - + + + + + + diff --git a/Jetsnack/app/src/main/res/drawable/widget_logo.xml b/Jetsnack/app/src/main/res/drawable/widget_logo.xml new file mode 100644 index 0000000000..36009cadda --- /dev/null +++ b/Jetsnack/app/src/main/res/drawable/widget_logo.xml @@ -0,0 +1,22 @@ + + + + diff --git a/Jetsnack/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetsnack/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 8ce0e286d1..ed2a1d867e 100644 --- a/Jetsnack/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/Jetsnack/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,4 @@ - - + + + + + 2 + 2 + 180dp + 184dp + 180dp + 184dp + 488dp + 488dp + \ No newline at end of file diff --git a/Jetsnack/app/src/main/res/values/colors.xml b/Jetsnack/app/src/main/res/values/colors.xml index 1336a20316..91ae7258f2 100644 --- a/Jetsnack/app/src/main/res/values/colors.xml +++ b/Jetsnack/app/src/main/res/values/colors.xml @@ -1,5 +1,4 @@ - - + + 0dp + 48dp + + + + + + + + 4 + 2 + 256dp + 115dp + 120dp + 115dp + 624dp + 422dp + \ No newline at end of file diff --git a/Jetsnack/app/src/main/res/values/strings.xml b/Jetsnack/app/src/main/res/values/strings.xml index 2e93c6ce2e..9c9299ff0f 100644 --- a/Jetsnack/app/src/main/res/values/strings.xml +++ b/Jetsnack/app/src/main/res/values/strings.xml @@ -1,5 +1,4 @@ - - Details - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud... + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut tempus, sem vitae convallis imperdiet, lectus nunc pharetra diam, ac rhoncus quam eros eu risus. Nulla pulvinar condimentum erat, pulvinar tempus turpis blandit ut. Etiam sed ipsum sed lacus eleifend hendrerit eu quis quam. Etiam ligula eros, finibus vestibulum tortor ac, ultrices accumsan dolor. Vivamus vel nisl a libero lobortis posuere. Aenean facilisis nibh vel ultrices bibendum. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Suspendisse ac est vitae lacus commodo efficitur at ut massa. Etiam vestibulum sit amet sapien sed varius. Aliquam non ipsum imperdiet, pulvinar enim nec, mollis risus. Fusce id tincidunt nisl. Ingredients Vanilla, Almond Flour, Eggs, Butter, Cream, Sugar Qty @@ -53,10 +52,37 @@ Shipping & Handling Total Checkout + There was an error and the quantity couldn\'t be increased. Please try again. + There was an error and the quantity couldn\'t be decreased. Please try again. Remove item Increase Decrease + This is currently work in progress + Grab a beverage and check back later! + SEE MORE + SEE LESS + Remove Item + Reset + Sort + Price + Category + Max Calories + LifeStyle + per serving + Android\'s Favorite (default) + Rating + Alphabetical + Close + + + Recent Jetsnack orders + Jetsnack Recent Orders + Quickly view and reorder your recent orders. + View shopping cart + No data + Add + Add to Shopping Cart diff --git a/Jetsnack/app/src/main/res/values/themes.xml b/Jetsnack/app/src/main/res/values/themes.xml index 12347755da..14776251c4 100644 --- a/Jetsnack/app/src/main/res/values/themes.xml +++ b/Jetsnack/app/src/main/res/values/themes.xml @@ -1,5 +1,4 @@ - - + \ No newline at end of file diff --git a/Jetsnack/build.gradle b/Jetsnack/build.gradle deleted file mode 100644 index 0f6afecb12..0000000000 --- a/Jetsnack/build.gradle +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.jetsnack.buildsrc.Libs -import com.example.jetsnack.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.10.0' -} - -subprojects { - repositories { - google() - mavenCentral() - jcenter() - - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { url "https://androidx.dev/snapshots/builds/${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" } - } - if (Libs.Accompanist.version.endsWith("SNAPSHOT")) { - maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - jvmTarget = '1.8' - allWarningsAsErrors = true - // Opt-in to experimental compose APIs - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - // Enable experimental coroutines APIs, including collectAsState() - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - - ktlint(Versions.ktlint).userData([android: "true"]) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } -} diff --git a/Jetsnack/build.gradle.kts b/Jetsnack/build.gradle.kts new file mode 100644 index 0000000000..e0ceb9e193 --- /dev/null +++ b/Jetsnack/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.gradle.versions) + alias(libs.plugins.version.catalog.update) + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.compose) apply false + alias(libs.plugins.spotless) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure { + kotlin { + target("**/*.kt") + targetExclude("${layout.buildDirectory}/**/*.kt") + ktlint() + licenseHeaderFile(rootProject.file("spotless/copyright.kt")) + } + kotlinGradle { + target("*.gradle.kts") + targetExclude("${layout.buildDirectory}/**/*.kt") + ktlint() + // Look for the first line that doesn't have a block comment (assumed to be the license) + licenseHeaderFile(rootProject.file("spotless/copyright.kt"), "(^(?![\\/ ]\\*).*$)") + } + } +} diff --git a/Jetsnack/buildSrc/build.gradle.kts b/Jetsnack/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Jetsnack/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Jetsnack/buildSrc/src/main/java/com/example/jetsnack/buildsrc/Dependencies.kt b/Jetsnack/buildSrc/src/main/java/com/example/jetsnack/buildsrc/Dependencies.kt deleted file mode 100644 index ef17794130..0000000000 --- a/Jetsnack/buildSrc/src/main/java/com/example/jetsnack/buildsrc/Dependencies.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetsnack.buildsrc - -object Versions { - const val ktlint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha11" - - object Accompanist { - const val version = "0.7.1-SNAPSHOT" - const val coil = "com.google.accompanist:accompanist-coil:$version" - const val insets = "com.google.accompanist:accompanist-insets:$version" - } - - object Kotlin { - private const val version = "1.4.31" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.1" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object AndroidX { - const val coreKtx = "androidx.core:core-ktx:1.5.0-beta03" - - object Compose { - const val snapshot = "" - const val version = "1.0.0-beta03" - - const val foundation = "androidx.compose.foundation:foundation:${version}" - const val layout = "androidx.compose.foundation:foundation-layout:${version}" - const val ui = "androidx.compose.ui:ui:${version}" - const val uiUtil = "androidx.compose.ui:ui-util:${version}" - const val runtime = "androidx.compose.runtime:runtime:${version}" - const val material = "androidx.compose.material:material:${version}" - const val animation = "androidx.compose.animation:animation:${version}" - const val tooling = "androidx.compose.ui:ui-tooling:${version}" - const val iconsExtended = "androidx.compose.material:material-icons-extended:$version" - } - - object Activity { - const val activityCompose = "androidx.activity:activity-compose:1.3.0-alpha05" - } - - object Lifecycle { - const val viewModelCompose = - "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha03" - } - - object ConstraintLayout { - const val constraintLayoutCompose = - "androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha03" - } - } -} diff --git a/Jetsnack/buildscripts/toml-updater-config.gradle b/Jetsnack/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/Jetsnack/buildscripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/Jetsnack/debug.keystore b/Jetsnack/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Jetsnack/debug.keystore and /dev/null differ diff --git a/Jetsnack/debug_2.keystore b/Jetsnack/debug_2.keystore new file mode 100644 index 0000000000..b42c971788 Binary files /dev/null and b/Jetsnack/debug_2.keystore differ diff --git a/Jetsnack/gradle.properties b/Jetsnack/gradle.properties index b2d834ce9c..9299bc6d0f 100644 --- a/Jetsnack/gradle.properties +++ b/Jetsnack/gradle.properties @@ -37,6 +37,3 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Jetsnack/gradle/libs.versions.toml b/Jetsnack/gradle/libs.versions.toml new file mode 100644 index 0000000000..b4f1a48c50 --- /dev/null +++ b/Jetsnack/gradle/libs.versions.toml @@ -0,0 +1,170 @@ +[versions] +accompanist = "0.37.3" +android-material3 = "1.14.0-rc01" +androidGradlePlugin = "9.2.1" +androidx-activity-compose = "1.13.0" +androidx-appcompat = "1.7.1" +androidx-compose-bom = "2026.05.00" +androidx-core-splashscreen = "1.2.0" +androidx-corektx = "1.18.0" +androidx-glance = "1.2.0-rc01" +androidx-lifecycle = "2.8.2" +androidx-lifecycle-compose = "2.10.0" +androidx-lifecycle-runtime-compose = "2.10.0" +androidx-navigation = "2.9.8" +androidx-palette = "1.0.0" +androidx-test = "1.7.0" +androidx-test-espresso = "3.7.0" +androidx-test-ext-junit = "1.3.0" +androidx-test-ext-truth = "1.7.0" +androidx-tv-foundation = "1.0.0" +androidx-tv-material = "1.1.0" +androidx-wear-compose = "1.6.1" +androidx-window = "1.5.1" +androidxHiltNavigationCompose = "1.3.0" +androix-test-uiautomator = "2.3.0" +coil = "2.7.0" +# @keep +compileSdk = "36" +coroutines = "1.11.0" +glancePreview = "1.1.1" +google-maps = "20.0.0" +gradle-versions = "0.54.0" +hilt = "2.59.2" +hiltExt = "1.3.0" +horologist = "0.7.15" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.3.21" +kotlinx-serialization-json = "1.11.0" +kotlinx_immutable = "0.4.0" +ksp = "2.3.7" +maps-compose = "8.3.0" +# @keep +minSdk = "23" +okhttp = "5.3.2" +play-services-wearable = "20.0.1" +robolectric = "4.16.1" +roborazzi = "1.60.0" +rome = "2.1.0" +room = "2.8.4" +secrets = "2.0.1" +spotless = "8.4.0" +# @keep +targetSdk = "36" +version-catalog-update = "1.1.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-glance-preview = { module = "androidx.glance:glance-preview", version.ref = "glancePreview" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } +androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androix-test-uiautomator" } +androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv-foundation" } +androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx-tv-material" } +androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" } +androidx-wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "androidx-wear-compose" } +androidx-wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "androidx-wear-compose" } +androidx-wear-compose-ui-tooling = { module = "androidx.wear.compose:compose-ui-tooling", version.ref = "androidx-wear-compose" } +androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } +androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" } +coil-kt-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +core-jdk-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "jdkDesugar" } +dagger-hiltandroidplugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } +googlemaps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" } +googlemaps-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "google-maps" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltExt" } +horologist-audio-ui = { module = "com.google.android.horologist:horologist-audio-ui", version.ref = "horologist" } +horologist-composables = { module = "com.google.android.horologist:horologist-composables", version.ref = "horologist" } +horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" } +horologist-compose-material = { module = "com.google.android.horologist:horologist-compose-material", version.ref = "horologist" } +horologist-compose-tools = { module = "com.google.android.horologist:horologist-compose-tools", version.ref = "horologist" } +horologist-images-coil = { module = "com.google.android.horologist:horologist-images-coil", version.ref = "horologist" } +horologist-media-data = { module = "com.google.android.horologist:horologist-media-data", version.ref = "horologist" } +horologist-media-ui = { module = "com.google.android.horologist:horologist-media-ui", version.ref = "horologist" } +horologist-roboscreenshots = { module = "com.google.android.horologist:horologist-roboscreenshots", version.ref = "horologist" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx_immutable" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" } +roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" } +roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" } +rometools-modules = { module = "com.rometools:rome-modules", version.ref = "rome" } +rometools-rome = { module = "com.rometools:rome", version.ref = "rome" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } +android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } +compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } +secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/Jetsnack/gradle/wrapper/gradle-wrapper.jar b/Jetsnack/gradle/wrapper/gradle-wrapper.jar index e708b1c023..d997cfc60f 100644 Binary files a/Jetsnack/gradle/wrapper/gradle-wrapper.jar and b/Jetsnack/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Jetsnack/gradle/wrapper/gradle-wrapper.properties b/Jetsnack/gradle/wrapper/gradle-wrapper.properties index 2a563242c1..c61a118f7d 100644 --- a/Jetsnack/gradle/wrapper/gradle-wrapper.properties +++ b/Jetsnack/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/Jetsnack/gradlew b/Jetsnack/gradlew index 4f906e0c81..739907dfd1 100755 --- a/Jetsnack/gradlew +++ b/Jetsnack/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,114 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/Jetsnack/gradlew.bat b/Jetsnack/gradlew.bat index ac1b06f938..e509b2dd8f 100644 --- a/Jetsnack/gradlew.bat +++ b/Jetsnack/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/Jetsnack/screenshots/screenshots.png b/Jetsnack/screenshots/screenshots.png new file mode 100644 index 0000000000..f1e2707434 Binary files /dev/null and b/Jetsnack/screenshots/screenshots.png differ diff --git a/Jetsnack/settings.gradle b/Jetsnack/settings.gradle deleted file mode 100644 index 62384f2525..0000000000 --- a/Jetsnack/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -include ':app' -rootProject.name = "Jetsnack" \ No newline at end of file diff --git a/Jetsnack/settings.gradle.kts b/Jetsnack/settings.gradle.kts new file mode 100644 index 0000000000..3bc8533030 --- /dev/null +++ b/Jetsnack/settings.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +val snapshotVersion : String? = System.getenv("COMPOSE_SNAPSHOT_ID") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + maven { url = uri("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.0.0-RC2-200/") } + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + snapshotVersion?.let { + println("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") + maven { url = uri("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") } + maven { url = uri("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.0.0-RC2-200/") } + } + + google() + mavenCentral() + } +} +rootProject.name = "Jetsnack" +include(":app") diff --git a/Jetsurvey/.gitignore b/Jetsurvey/.gitignore deleted file mode 100644 index dcd0ee3b86..0000000000 --- a/Jetsurvey/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea -.DS_Store -/build -/captures -.externalNativeBuild -/projectFilesBackup diff --git a/Jetsurvey/.google/packaging.yaml b/Jetsurvey/.google/packaging.yaml deleted file mode 100644 index 84aff54914..0000000000 --- a/Jetsurvey/.google/packaging.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (C) 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# GOOGLE SAMPLE PACKAGING DATA -# -# This file is used by Google as part of our samples packaging process. -# End users may safely ignore this file. It has no relevance to other systems. ---- -status: PUBLISHED -technologies: [Android] -categories: [Compose] -languages: [Kotlin] -solutions: [Mobile] -github: android/compose-samples -level: BEGINNER -apiRefs: - - android:androidx.compose.Composable -license: apache2 diff --git a/Jetsurvey/README.md b/Jetsurvey/README.md deleted file mode 100644 index 6268539bdc..0000000000 --- a/Jetsurvey/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# Jetsurvey sample - -Jetsurvey is a sample survey app, built with -[Jetpack Compose](https://developer.android.com/jetpack/compose). The goal of the sample is to -showcase text input, validation and state capabilities of Compose. - -To try out these sample apps, you need to use the latest Canary version of Android Studio 4.2. -You can clone this repository or import the -project from Android Studio following the steps -[here](https://developer.android.com/jetpack/compose/setup#sample). - -Screenshots ------------ - - -## Features - -This sample contains several screens: a welcome screen, where the user can enter their email, sign in and sign up screens and a survey screen. The app has light and dark themes. - -### App scaffolding - -Package [`com.example.compose.jetsurvey`][1] - -[`MainActivity`][2] is the application's entry point. Each screen is implemented inside a `Fragment` and [`MainActivity`][2] is the host `Activity` for all of the `Fragment`s. -The navigation between them uses the [Navigation library][3]. The screens and the navigation are defined in [`Navigation.kt`][4] - -[1]: app/src/main/java/com/example/compose/jetsurvey -[2]: app/src/main/java/com/example/compose/jetsurvey/MainActivity.kt -[3]: https://developer.android.com/guide/navigation -[4]: app/src/main/java/com/example/compose/jetsurvey/Navigation.kt - -### Sign in/sign up - -Package [`com.example.compose.jetsurvey.signinsignup`][5] - -This package contains 3 screens: -* Welcome -* Sign in -* Sign up - -To get to the sign up screen, enter an email that contains "signup". -These screens show how to create different custom composable functions, reused them across multiple screens and handle UI state. - -See how to: - -* Use `TextField`s -* Implement `TextField` validation across one `TextField` (e.g. email validation) and across multiple `TextFields` (e.g. password confirmation) -* Use a `Snackbar` -* Use different types of `Button`s: `TextButton`, `OutlinedButton` and `Button` - -[5]: app/src/main/java/com/example/compose/jetsurvey/signinsignup - -### Complete a survey - -Package [`com.example.compose.jetsurvey.survey`][6] - -This screen allows the user to fill out a survey, showing how to handle complex state. UI state is kept and restored on recompositions triggered by different reasons like a configuration change or a new question being displayed on the screen. - -See how to: - -* Use `RadioButton`s - for single item selection -* Use `Checkbox`es - for multi-item selection -* Use `Slider` - for picking a value from a range -* Use `Scaffold` - for screens with top bar, bottom bar and body -* Display a `DialogFragment` when requested from compose - -[6]: app/src/main/java/com/example/compose/jetsurvey/survey - -### Data - -The data in the sample is static, held in the `*Repository` classes. - -## Setup -The main [README](https://github.com/android/compose-samples/) has instructions on how to -setup this sample, and many others. - -## License - -``` -Copyright 2020 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` diff --git a/Jetsurvey/app/.gitignore b/Jetsurvey/app/.gitignore deleted file mode 100644 index 796b96d1c4..0000000000 --- a/Jetsurvey/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/Jetsurvey/app/build.gradle b/Jetsurvey/app/build.gradle deleted file mode 100644 index 43388d07d3..0000000000 --- a/Jetsurvey/app/build.gradle +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import com.example.compose.jetsurvey.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' -} - -android { - compileSdkVersion 30 - - defaultConfig { - applicationId "com.example.compose.jetsurvey" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - buildFeatures { - compose true - - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } - - packagingOptions { - excludes += "/META-INF/AL2.0" - excludes += "/META-INF/LGPL2.1" - } - - // TODO: Fix lint errors and remove - lintOptions { - abortOnError false - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - implementation Libs.AndroidX.coreKtx - implementation Libs.AndroidX.appcompat - implementation Libs.AndroidX.Navigation.fragment - implementation Libs.AndroidX.Navigation.uiKtx - implementation Libs.AndroidX.Material.material - implementation Libs.material - - implementation Libs.AndroidX.Lifecycle.viewmodel - implementation Libs.AndroidX.Lifecycle.viewModelCompose - - implementation Libs.AndroidX.Activity.activityCompose - - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.materialIconsExtended - implementation Libs.AndroidX.Compose.tooling - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.runtimeLivedata - - implementation Libs.Accompanist.coil -} diff --git a/Jetsurvey/app/proguard-rules.pro b/Jetsurvey/app/proguard-rules.pro deleted file mode 100644 index 4cb94585a0..0000000000 --- a/Jetsurvey/app/proguard-rules.pro +++ /dev/null @@ -1,24 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile - -# Repackage classes into the top-level. --repackageclasses diff --git a/Jetsurvey/app/src/main/AndroidManifest.xml b/Jetsurvey/app/src/main/AndroidManifest.xml deleted file mode 100644 index 1688a9162e..0000000000 --- a/Jetsurvey/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/MainActivity.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/MainActivity.kt deleted file mode 100644 index d532c6080a..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/MainActivity.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.navigation.findNavController -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.navigation.NavigationView - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - val navView: NavigationView = findViewById(R.id.nav_view) - val navController = findNavController(R.id.nav_host_fragment) - navView.setupWithNavController(navController) - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/Navigation.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/Navigation.kt deleted file mode 100644 index afb0aa601c..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/Navigation.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey - -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import java.security.InvalidParameterException - -enum class Screen { Welcome, SignUp, SignIn, Survey } - -fun Fragment.navigate(to: Screen, from: Screen) { - if (to == from) { - throw InvalidParameterException("Can't navigate to $to") - } - when (to) { - Screen.Welcome -> { - findNavController().navigate(R.id.welcome_fragment) - } - Screen.SignUp -> { - findNavController().navigate(R.id.sign_up_fragment) - } - Screen.SignIn -> { - findNavController().navigate(R.id.sign_in_fragment) - } - Screen.Survey -> { - findNavController().navigate(R.id.survey_fragment) - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/EmailState.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/EmailState.kt deleted file mode 100644 index 0933c77f20..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/EmailState.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import java.util.regex.Pattern - -// Consider an email valid if there's some text before and after a "@" -private const val EMAIL_VALIDATION_REGEX = "^(.+)@(.+)\$" - -class EmailState : - TextFieldState(validator = ::isEmailValid, errorFor = ::emailValidationError) - -/** - * Returns an error to be displayed or null if no error was found - */ -private fun emailValidationError(email: String): String { - return "Invalid email: $email" -} - -private fun isEmailValid(email: String): Boolean { - return Pattern.matches(EMAIL_VALIDATION_REGEX, email) -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/PasswordState.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/PasswordState.kt deleted file mode 100644 index 4b1ffe32db..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/PasswordState.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -class PasswordState : - TextFieldState(validator = ::isPasswordValid, errorFor = ::passwordValidationError) - -class ConfirmPasswordState(private val passwordState: PasswordState) : TextFieldState() { - override val isValid - get() = passwordAndConfirmationValid(passwordState.text, text) - - override fun getError(): String? { - return if (showErrors()) { - passwordConfirmationError() - } else { - null - } - } -} - -private fun passwordAndConfirmationValid(password: String, confirmedPassword: String): Boolean { - return isPasswordValid(password) && password == confirmedPassword -} - -private fun isPasswordValid(password: String): Boolean { - return password.length > 3 -} - -@Suppress("UNUSED_PARAMETER") -private fun passwordValidationError(password: String): String { - return "Invalid password" -} - -private fun passwordConfirmationError(): String { - return "Passwords don't match" -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInFragment.kt deleted file mode 100644 index d2dde3181b..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInFragment.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.navigate -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -/** - * Fragment containing the sign in UI. - */ -class SignInFragment : Fragment() { - - private val viewModel: SignInViewModel by viewModels { SignInViewModelFactory() } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel.navigateTo.observe(viewLifecycleOwner) { navigateToEvent -> - navigateToEvent.getContentIfNotHandled()?.let { navigateTo -> - navigate(navigateTo, Screen.SignIn) - } - } - - return ComposeView(requireContext()).apply { - // In order for savedState to work, the same ID needs to be used for all instances. - id = R.id.sign_in_fragment - - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - setContent { - JetsurveyTheme { - SignIn( - onNavigationEvent = { event -> - when (event) { - is SignInEvent.SignIn -> { - viewModel.signIn(event.email, event.password) - } - SignInEvent.SignUp -> { - viewModel.signUp() - } - SignInEvent.SignInAsGuest -> { - viewModel.signInAsGuest() - } - SignInEvent.NavigateBack -> { - activity?.onBackPressedDispatcher?.onBackPressed() - } - } - } - ) - } - } - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInScreen.kt deleted file mode 100644 index 610330dd3e..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInScreen.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -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.layout.wrapContentHeight -import androidx.compose.material.Button -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Snackbar -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme -import com.example.compose.jetsurvey.theme.snackbarAction -import kotlinx.coroutines.launch - -sealed class SignInEvent { - data class SignIn(val email: String, val password: String) : SignInEvent() - object SignUp : SignInEvent() - object SignInAsGuest : SignInEvent() - object NavigateBack : SignInEvent() -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun SignIn(onNavigationEvent: (SignInEvent) -> Unit) { - - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - - val snackbarErrorText = stringResource(id = R.string.feature_not_available) - val snackbarActionLabel = stringResource(id = R.string.dismiss) - - Scaffold( - topBar = { - SignInSignUpTopAppBar( - topAppBarText = stringResource(id = R.string.sign_in), - onBackPressed = { onNavigationEvent(SignInEvent.NavigateBack) } - ) - }, - content = { - SignInSignUpScreen( - onSignedInAsGuest = { onNavigationEvent(SignInEvent.SignInAsGuest) }, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.fillMaxWidth()) { - SignInContent( - onSignInSubmitted = { email, password -> - onNavigationEvent(SignInEvent.SignIn(email, password)) - } - ) - Spacer(modifier = Modifier.height(16.dp)) - TextButton( - onClick = { - scope.launch { - snackbarHostState.showSnackbar( - message = snackbarErrorText, - actionLabel = snackbarActionLabel - ) - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.forgot_password)) - } - } - } - } - ) - - Box(modifier = Modifier.fillMaxSize()) { - ErrorSnackbar( - snackbarHostState = snackbarHostState, - onDismiss = { snackbarHostState.currentSnackbarData?.dismiss() }, - modifier = Modifier.align(Alignment.BottomCenter) - ) - } -} - -@Composable -fun SignInContent( - onSignInSubmitted: (email: String, password: String) -> Unit, -) { - Column(modifier = Modifier.fillMaxWidth()) { - val focusRequester = remember { FocusRequester() } - val emailState = remember { EmailState() } - Email(emailState, onImeAction = { focusRequester.requestFocus() }) - - Spacer(modifier = Modifier.height(16.dp)) - - val passwordState = remember { PasswordState() } - Password( - label = stringResource(id = R.string.password), - passwordState = passwordState, - modifier = Modifier.focusRequester(focusRequester), - onImeAction = { onSignInSubmitted(emailState.text, passwordState.text) } - ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { onSignInSubmitted(emailState.text, passwordState.text) }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - enabled = emailState.isValid && passwordState.isValid - ) { - Text( - text = stringResource(id = R.string.sign_in) - ) - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun ErrorSnackbar( - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, - onDismiss: () -> Unit = { } -) { - SnackbarHost( - hostState = snackbarHostState, - snackbar = { data -> - Snackbar( - modifier = Modifier.padding(16.dp), - content = { - Text( - text = data.message, - style = MaterialTheme.typography.body2 - ) - }, - action = { - data.actionLabel?.let { - TextButton(onClick = onDismiss) { - Text( - text = stringResource(id = R.string.dismiss), - color = MaterialTheme.colors.snackbarAction - ) - } - } - } - ) - }, - modifier = modifier - .fillMaxWidth() - .wrapContentHeight(Alignment.Bottom) - ) -} - -@Preview(name = "Sign in light theme") -@Composable -fun SignInPreview() { - JetsurveyTheme { - SignIn {} - } -} - -@Preview(name = "Sign in dark theme") -@Composable -fun SignInPreviewDark() { - JetsurveyTheme(darkTheme = true) { - SignIn {} - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInSignUp.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInSignUp.kt deleted file mode 100644 index dde485b5a2..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInSignUp.kt +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -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.layout.width -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronLeft -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusState -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R - -@Composable -fun SignInSignUpScreen( - onSignedInAsGuest: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable() () -> Unit -) { - LazyColumn(modifier = modifier) { - item { - Spacer(modifier = Modifier.height(44.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - ) { - content() - } - Spacer(modifier = Modifier.height(16.dp)) - OrSignInAsGuest( - onSignedInAsGuest = onSignedInAsGuest, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - ) - } - } -} - -@Composable -fun SignInSignUpTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) { - TopAppBar( - title = { - Text( - text = topAppBarText, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center) - ) - }, - navigationIcon = { - IconButton(onClick = onBackPressed) { - Icon( - imageVector = Icons.Filled.ChevronLeft, - contentDescription = stringResource(id = R.string.back) - ) - } - }, - // We need to balance the navigation icon, so we add a spacer. - actions = { - Spacer(modifier = Modifier.width(68.dp)) - }, - backgroundColor = MaterialTheme.colors.surface, - elevation = 0.dp - ) -} - -@Composable -fun Email( - emailState: TextFieldState = remember { EmailState() }, - imeAction: ImeAction = ImeAction.Next, - onImeAction: () -> Unit = {} -) { - OutlinedTextField( - value = emailState.text, - onValueChange = { - emailState.text = it - }, - label = { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.email), - style = MaterialTheme.typography.body2 - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focusState -> - val focused = focusState == FocusState.Active - emailState.onFocusChange(focused) - if (!focused) { - emailState.enableShowErrors() - } - }, - textStyle = MaterialTheme.typography.body2, - isError = emailState.showErrors(), - keyboardOptions = KeyboardOptions.Default.copy(imeAction = imeAction), - keyboardActions = KeyboardActions( - onDone = { - onImeAction() - } - ) - ) - - emailState.getError()?.let { error -> TextFieldError(textError = error) } -} - -@Composable -fun Password( - label: String, - passwordState: TextFieldState, - modifier: Modifier = Modifier, - imeAction: ImeAction = ImeAction.Done, - onImeAction: () -> Unit = {} -) { - val showPassword = remember { mutableStateOf(false) } - OutlinedTextField( - value = passwordState.text, - onValueChange = { - passwordState.text = it - passwordState.enableShowErrors() - }, - modifier = modifier - .fillMaxWidth() - .onFocusChanged { focusState -> - val focused = focusState == FocusState.Active - passwordState.onFocusChange(focused) - if (!focused) { - passwordState.enableShowErrors() - } - }, - textStyle = MaterialTheme.typography.body2, - label = { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = label, - style = MaterialTheme.typography.body2 - ) - } - }, - trailingIcon = { - if (showPassword.value) { - IconButton(onClick = { showPassword.value = false }) { - Icon( - imageVector = Icons.Filled.Visibility, - contentDescription = stringResource(id = R.string.hide_password) - ) - } - } else { - IconButton(onClick = { showPassword.value = true }) { - Icon( - imageVector = Icons.Filled.VisibilityOff, - contentDescription = stringResource(id = R.string.show_password) - ) - } - } - }, - visualTransformation = if (showPassword.value) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - isError = passwordState.showErrors(), - keyboardOptions = KeyboardOptions.Default.copy(imeAction = imeAction), - keyboardActions = KeyboardActions( - onDone = { - onImeAction() - } - ) - ) - - passwordState.getError()?.let { error -> TextFieldError(textError = error) } -} - -/** - * To be removed when [TextField]s support error - */ -@Composable -fun TextFieldError(textError: String) { - Row(modifier = Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = textError, - modifier = Modifier.fillMaxWidth(), - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.error) - ) - } -} - -@Composable -fun OrSignInAsGuest( - onSignedInAsGuest: () -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Surface { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.or), - style = MaterialTheme.typography.subtitle2 - ) - } - } - OutlinedButton( - onClick = onSignedInAsGuest, - modifier = Modifier - .fillMaxWidth() - .padding(top = 20.dp, bottom = 24.dp) - ) { - Text(text = stringResource(id = R.string.sign_in_guest)) - } - } -} - -@Preview -@Composable -fun SignInSignUpScreenPreview() { - SignInSignUpScreen( - onSignedInAsGuest = {}, - content = {} - ) -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInViewModel.kt deleted file mode 100644 index 76ebde0bef..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInViewModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.Screen.SignUp -import com.example.compose.jetsurvey.Screen.Survey -import com.example.compose.jetsurvey.util.Event - -class SignInViewModel(private val userRepository: UserRepository) : ViewModel() { - - private val _navigateTo = MutableLiveData>() - val navigateTo: LiveData> - get() = _navigateTo - - /** - * Consider all sign ins successful - */ - fun signIn(email: String, password: String) { - userRepository.signIn(email, password) - _navigateTo.value = Event(Survey) - } - - fun signInAsGuest() { - userRepository.signInAsGuest() - _navigateTo.value = Event(Survey) - } - - fun signUp() { - _navigateTo.value = Event(SignUp) - } -} - -@Suppress("UNCHECKED_CAST") -class SignInViewModelFactory : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(SignInViewModel::class.java)) { - return SignInViewModel(UserRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpFragment.kt deleted file mode 100644 index df9c61728a..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpFragment.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.navigate -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -/** - * Fragment containing the sign up UI - */ -class SignUpFragment : Fragment() { - - private val viewModel: SignUpViewModel by viewModels { SignUpViewModelFactory() } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - viewModel.navigateTo.observe(viewLifecycleOwner) { navigateToEvent -> - navigateToEvent.getContentIfNotHandled()?.let { navigateTo -> - navigate(navigateTo, Screen.SignUp) - } - } - - return ComposeView(requireContext()).apply { - // In order for savedState to work, the same ID needs to be used for all instances. - id = R.id.sign_up_fragment - - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - setContent { - JetsurveyTheme { - SignUp( - onNavigationEvent = { event -> - when (event) { - is SignUpEvent.SignUp -> { - viewModel.signUp(event.email, event.password) - } - SignUpEvent.SignIn -> { - viewModel.signIn() - } - SignUpEvent.SignInAsGuest -> { - viewModel.signInAsGuest() - } - SignUpEvent.NavigateBack -> { - activity?.onBackPressedDispatcher?.onBackPressed() - } - } - } - ) - } - } - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpScreen.kt deleted file mode 100644 index f0a983d7aa..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpScreen.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material.Button -import androidx.compose.material.ContentAlpha -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -sealed class SignUpEvent { - object SignIn : SignUpEvent() - data class SignUp(val email: String, val password: String) : SignUpEvent() - object SignInAsGuest : SignUpEvent() - object NavigateBack : SignUpEvent() -} - -@Composable -fun SignUp(onNavigationEvent: (SignUpEvent) -> Unit) { - Scaffold( - topBar = { - SignInSignUpTopAppBar( - topAppBarText = stringResource(id = R.string.create_account), - onBackPressed = { onNavigationEvent(SignUpEvent.NavigateBack) } - ) - }, - content = { - SignInSignUpScreen( - onSignedInAsGuest = { onNavigationEvent(SignUpEvent.SignInAsGuest) }, - modifier = Modifier.fillMaxWidth() - ) { - Column { - SignUpContent( - onSignUpSubmitted = { email, password -> - onNavigationEvent(SignUpEvent.SignUp(email, password)) - } - ) - } - } - } - ) -} - -@Composable -fun SignUpContent( - onSignUpSubmitted: (email: String, password: String) -> Unit, -) { - Column(modifier = Modifier.fillMaxWidth()) { - val passwordFocusRequest = remember { FocusRequester() } - val confirmationPasswordFocusRequest = remember { FocusRequester() } - val emailState = remember { EmailState() } - Email(emailState, onImeAction = { passwordFocusRequest.requestFocus() }) - - Spacer(modifier = Modifier.height(16.dp)) - val passwordState = remember { PasswordState() } - Password( - label = stringResource(id = R.string.password), - passwordState = passwordState, - imeAction = ImeAction.Next, - onImeAction = { confirmationPasswordFocusRequest.requestFocus() }, - modifier = Modifier.focusRequester(passwordFocusRequest) - ) - - Spacer(modifier = Modifier.height(16.dp)) - val confirmPasswordState = remember { ConfirmPasswordState(passwordState = passwordState) } - Password( - label = stringResource(id = R.string.confirm_password), - passwordState = confirmPasswordState, - onImeAction = { onSignUpSubmitted(emailState.text, passwordState.text) }, - modifier = Modifier.focusRequester(confirmationPasswordFocusRequest) - ) - - Spacer(modifier = Modifier.height(16.dp)) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.terms_and_conditions), - style = MaterialTheme.typography.caption - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { onSignUpSubmitted(emailState.text, passwordState.text) }, - modifier = Modifier.fillMaxWidth(), - enabled = emailState.isValid && - passwordState.isValid && confirmPasswordState.isValid - ) { - Text(text = stringResource(id = R.string.create_account)) - } - } -} - -@Preview -@Composable -fun SignUpPreview() { - JetsurveyTheme { - SignUp {} - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpViewModel.kt deleted file mode 100644 index 024941a543..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpViewModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.Screen.SignIn -import com.example.compose.jetsurvey.Screen.Survey -import com.example.compose.jetsurvey.util.Event - -class SignUpViewModel(private val userRepository: UserRepository) : ViewModel() { - - private val _navigateTo = MutableLiveData>() - val navigateTo: LiveData> - get() = _navigateTo - - /** - * Consider all sign ups successful - */ - fun signUp(email: String, password: String) { - userRepository.signUp(email, password) - _navigateTo.value = Event(Survey) - } - - fun signInAsGuest() { - userRepository.signInAsGuest() - _navigateTo.value = Event(Survey) - } - - fun signIn() { - _navigateTo.value = Event(SignIn) - } -} - -class SignUpViewModelFactory : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(SignUpViewModel::class.java)) { - return SignUpViewModel(UserRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/TextFieldState.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/TextFieldState.kt deleted file mode 100644 index 5818b86666..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/TextFieldState.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -open class TextFieldState( - private val validator: (String) -> Boolean = { true }, - private val errorFor: (String) -> String = { "" } -) { - var text: String by mutableStateOf("") - // was the TextField ever focused - var isFocusedDirty: Boolean by mutableStateOf(false) - var isFocused: Boolean by mutableStateOf(false) - private var displayErrors: Boolean by mutableStateOf(false) - - open val isValid: Boolean - get() = validator(text) - - fun onFocusChange(focused: Boolean) { - isFocused = focused - if (focused) isFocusedDirty = true - } - - fun enableShowErrors() { - // only show errors if the text was at least once focused - if (isFocusedDirty) { - displayErrors = true - } - } - - fun showErrors() = !isValid && displayErrors - - open fun getError(): String? { - return if (showErrors()) { - errorFor(text) - } else { - null - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/UserRepository.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/UserRepository.kt deleted file mode 100644 index 7401dc4dd0..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/UserRepository.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.runtime.Immutable - -sealed class User { - @Immutable - data class LoggedInUser(val email: String) : User() - object GuestUser : User() - object NoUserLoggedIn : User() -} - -/** - * Repository that holds the logged in user. - * - * In a production app, this class would also handle the communication with the backend for - * sign in and sign up. - */ -object UserRepository { - - private var _user: User = User.NoUserLoggedIn - val user: User - get() = _user - - @Suppress("UNUSED_PARAMETER") - fun signIn(email: String, password: String) { - _user = User.LoggedInUser(email) - } - - @Suppress("UNUSED_PARAMETER") - fun signUp(email: String, password: String) { - _user = User.LoggedInUser(email) - } - - fun signInAsGuest() { - _user = User.GuestUser - } - - fun isKnownUserEmail(email: String): Boolean { - // if the email contains "sign up" we consider it unknown - return !email.contains("signup") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeFragment.kt deleted file mode 100644 index 72d97de488..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeFragment.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.navigate -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -/** - * Fragment containing the welcome UI. - */ -class WelcomeFragment : Fragment() { - - private val viewModel: WelcomeViewModel by viewModels { WelcomeViewModelFactory() } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel.navigateTo.observe(viewLifecycleOwner) { navigateToEvent -> - navigateToEvent.getContentIfNotHandled()?.let { navigateTo -> - navigate(navigateTo, Screen.Welcome) - } - } - - return ComposeView(requireContext()).apply { - setContent { - JetsurveyTheme { - WelcomeScreen( - onEvent = { event -> - when (event) { - is WelcomeEvent.SignInSignUp -> viewModel.handleContinue( - event.email - ) - WelcomeEvent.SignInAsGuest -> viewModel.signInAsGuest() - } - } - ) - } - } - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeScreen.kt deleted file mode 100644 index c8334fe0b5..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeScreen.kt +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.Button -import androidx.compose.material.ContentAlpha -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.boundsInParent -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -sealed class WelcomeEvent { - data class SignInSignUp(val email: String) : WelcomeEvent() - object SignInAsGuest : WelcomeEvent() -} - -@Composable -fun WelcomeScreen(onEvent: (WelcomeEvent) -> Unit) { - var brandingBottom by remember { mutableStateOf(0f) } - var showBranding by remember { mutableStateOf(true) } - var heightWithBranding by remember { mutableStateOf(0) } - - val currentOffsetHolder = remember { mutableStateOf(0f) } - currentOffsetHolder.value = if (showBranding) 0f else -brandingBottom - val currentOffsetHolderDp = - with(LocalDensity.current) { currentOffsetHolder.value.toDp() } - val heightDp = with(LocalDensity.current) { heightWithBranding.toDp() } - Surface(modifier = Modifier.fillMaxSize()) { - val offset by animateDpAsState(targetValue = currentOffsetHolderDp) - Column( - modifier = Modifier - .fillMaxWidth() - .brandingPreferredHeight(showBranding, heightDp) - .offset(y = offset) - .onSizeChanged { - if (showBranding) { - heightWithBranding = it.height - } - } - ) { - Branding( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .onGloballyPositioned { - if (brandingBottom == 0f) { - brandingBottom = it.boundsInParent().bottom - } - } - ) - SignInCreateAccount( - onEvent = onEvent, - onFocusChange = { focused -> showBranding = !focused }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - ) - } - } -} - -private fun Modifier.brandingPreferredHeight( - showBranding: Boolean, - heightDp: Dp -): Modifier { - return if (!showBranding) { - this - .wrapContentHeight(unbounded = true) - .height(heightDp) - } else { - this - } -} - -@Composable -private fun Branding(modifier: Modifier = Modifier) { - Column( - modifier = modifier.wrapContentHeight(align = Alignment.CenterVertically) - ) { - Logo( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(horizontal = 76.dp) - ) - Text( - text = stringResource(id = R.string.app_tagline), - style = MaterialTheme.typography.subtitle1, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(top = 24.dp) - .fillMaxWidth() - ) - } -} - -@Composable -private fun Logo( - modifier: Modifier = Modifier, - lightTheme: Boolean = MaterialTheme.colors.isLight -) { - val assetId = if (lightTheme) { - R.drawable.ic_logo_light - } else { - R.drawable.ic_logo_dark - } - Image( - painter = painterResource(id = assetId), - modifier = modifier, - contentDescription = null - ) -} - -@Composable -private fun SignInCreateAccount( - onEvent: (WelcomeEvent) -> Unit, - onFocusChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - val emailState = remember { EmailState() } - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.sign_in_create_account), - style = MaterialTheme.typography.subtitle2, - textAlign = TextAlign.Center, - modifier = Modifier.padding(vertical = 24.dp) - ) - } - val onSubmit = { - if (emailState.isValid) { - onEvent(WelcomeEvent.SignInSignUp(emailState.text)) - } else { - emailState.enableShowErrors() - } - } - onFocusChange(emailState.isFocused) - Email(emailState = emailState, imeAction = ImeAction.Done, onImeAction = onSubmit) - Button( - onClick = onSubmit, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 28.dp) - ) { - Text( - text = stringResource(id = R.string.user_continue), - style = MaterialTheme.typography.subtitle2 - ) - } - OrSignInAsGuest( - onSignedInAsGuest = { onEvent(WelcomeEvent.SignInAsGuest) }, - modifier = Modifier.fillMaxWidth() - ) - } -} - -@Preview(name = "Welcome light theme") -@Composable -fun WelcomeScreenPreview() { - JetsurveyTheme { - WelcomeScreen {} - } -} - -@Preview(name = "Welcome dark theme") -@Composable -fun WelcomeScreenPreviewDark() { - JetsurveyTheme(darkTheme = true) { - WelcomeScreen {} - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeViewModel.kt deleted file mode 100644 index 073780f771..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeViewModel.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.Screen.SignIn -import com.example.compose.jetsurvey.Screen.SignUp -import com.example.compose.jetsurvey.Screen.Survey -import com.example.compose.jetsurvey.util.Event - -class WelcomeViewModel(private val userRepository: UserRepository) : ViewModel() { - - private val _navigateTo = MutableLiveData>() - val navigateTo: LiveData> = _navigateTo - - fun handleContinue(email: String) { - if (userRepository.isKnownUserEmail(email)) { - _navigateTo.value = Event(SignIn) - } else { - _navigateTo.value = Event(SignUp) - } - } - - fun signInAsGuest() { - userRepository.signInAsGuest() - _navigateTo.value = Event(Survey) - } -} - -@Suppress("UNCHECKED_CAST") -class WelcomeViewModelFactory : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(WelcomeViewModel::class.java)) { - return WelcomeViewModel(UserRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/PhotoUriManager.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/PhotoUriManager.kt deleted file mode 100644 index 8ae71d8205..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/PhotoUriManager.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.content.ContentValues -import android.content.Context -import android.provider.MediaStore - -/** - * Manages the creation of photo Uris. The Uri is used to store the photos taken with camera. - */ -class PhotoUriManager(private val appContext: Context) { - - private val photoCollection by lazy { - MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - } - - private val resolver by lazy { appContext.contentResolver } - - fun buildNewUri() = resolver.insert(photoCollection, buildPhotoDetails()) - - private fun buildPhotoDetails() = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, generateFilename()) - put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") - } - - /** - * Create a unique file name based on the time the photo is taken - */ - private fun generateFilename() = "selfie-${System.currentTimeMillis()}.jpg" -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt deleted file mode 100644 index 8605d1835e..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.net.Uri -import androidx.annotation.StringRes - -data class SurveyResult( - val library: String, - @StringRes val result: Int, - @StringRes val description: Int -) - -data class Survey( - @StringRes val title: Int, - val questions: List -) - -data class Question( - val id: Int, - @StringRes val questionText: Int, - val answer: PossibleAnswer, - @StringRes val description: Int? = null -) - -/** - * Type of supported actions for a survey - */ -enum class SurveyActionType { PICK_DATE, TAKE_PHOTO, SELECT_CONTACT } - -sealed class SurveyActionResult { - data class Date(val date: String) : SurveyActionResult() - data class Photo(val uri: Uri) : SurveyActionResult() - data class Contact(val contact: String) : SurveyActionResult() -} - -sealed class PossibleAnswer { - data class SingleChoice(val optionsStringRes: List) : PossibleAnswer() - data class MultipleChoice(val optionsStringRes: List) : PossibleAnswer() - data class Action( - @StringRes val label: Int, - val actionType: SurveyActionType - ) : PossibleAnswer() - - data class Slider( - val range: ClosedFloatingPointRange, - val steps: Int, - @StringRes val startText: Int, - @StringRes val endText: Int, - val defaultValue: Float = range.start - ) : PossibleAnswer() -} - -sealed class Answer { - data class SingleChoice(@StringRes val answer: Int) : Answer() - data class MultipleChoice(val answersStringRes: Set) : - Answer() - - data class Action(val result: SurveyActionResult) : Answer() - data class Slider(val answerValue: Float) : Answer() -} - -/** - * Add or remove an answer from the list of selected answers depending on whether the answer was - * selected or deselected. - */ -fun Answer.MultipleChoice.withAnswerSelected( - @StringRes answer: Int, - selected: Boolean -): Answer.MultipleChoice { - val newStringRes = answersStringRes.toMutableSet() - if (!selected) { - newStringRes.remove(answer) - } else { - newStringRes.add(answer) - } - return Answer.MultipleChoice(newStringRes) -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt deleted file mode 100644 index 5a0f56027f..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts.TakePicture -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme -import com.google.android.material.datepicker.MaterialDatePicker - -class SurveyFragment : Fragment() { - - private val viewModel: SurveyViewModel by viewModels { - SurveyViewModelFactory(PhotoUriManager(requireContext().applicationContext)) - } - - private val takePicture = registerForActivityResult(TakePicture()) { photoSaved -> - if (photoSaved) { - viewModel.onImageSaved() - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return ComposeView(requireContext()).apply { - // In order for savedState to work, the same ID needs to be used for all instances. - id = R.id.sign_in_fragment - - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - setContent { - JetsurveyTheme { - viewModel.uiState.observeAsState().value?.let { surveyState -> - when (surveyState) { - is SurveyState.Questions -> SurveyQuestionsScreen( - questions = surveyState, - onAction = { id, action -> handleSurveyAction(id, action) }, - onDonePressed = { viewModel.computeResult(surveyState) }, - onBackPressed = { - activity?.onBackPressedDispatcher?.onBackPressed() - } - ) - is SurveyState.Result -> SurveyResultScreen( - result = surveyState, - onDonePressed = { - activity?.onBackPressedDispatcher?.onBackPressed() - } - ) - } - } - } - } - } - } - - private fun handleSurveyAction(questionId: Int, actionType: SurveyActionType) { - when (actionType) { - SurveyActionType.PICK_DATE -> showDatePicker(questionId) - SurveyActionType.TAKE_PHOTO -> takeAPhoto() - SurveyActionType.SELECT_CONTACT -> selectContact(questionId) - } - } - - private fun showDatePicker(questionId: Int) { - val picker = MaterialDatePicker.Builder.datePicker().build() - activity?.let { - picker.show(it.supportFragmentManager, picker.toString()) - picker.addOnPositiveButtonClickListener { - viewModel.onDatePicked(questionId, picker.headerText) - } - } - } - - private fun takeAPhoto() { - takePicture.launch(viewModel.getUriToSaveImage()) - } - - @Suppress("UNUSED_PARAMETER") - private fun selectContact(questionId: Int) { - // TODO: unsupported for now - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt deleted file mode 100644 index d3ad518e45..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt +++ /dev/null @@ -1,446 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import androidx.annotation.StringRes -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.layout.width -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.selection.selectable -import androidx.compose.material.Button -import androidx.compose.material.Checkbox -import androidx.compose.material.CheckboxDefaults -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.RadioButton -import androidx.compose.material.RadioButtonDefaults -import androidx.compose.material.Slider -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddAPhoto -import androidx.compose.material.icons.filled.SwapHoriz -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme -import com.google.accompanist.coil.CoilImage - -@Composable -fun Question( - question: Question, - answer: Answer<*>?, - onAnswer: (Answer<*>) -> Unit, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn( - modifier = modifier, - contentPadding = PaddingValues(start = 20.dp, end = 20.dp) - ) { - item { - Spacer(modifier = Modifier.height(44.dp)) - val backgroundColor = if (MaterialTheme.colors.isLight) { - MaterialTheme.colors.onSurface.copy(alpha = 0.04f) - } else { - MaterialTheme.colors.onSurface.copy(alpha = 0.06f) - } - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = backgroundColor, - shape = MaterialTheme.shapes.small - ) - ) { - Text( - text = stringResource(id = question.questionText), - style = MaterialTheme.typography.subtitle1, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp, horizontal = 16.dp) - ) - } - Spacer(modifier = Modifier.height(24.dp)) - if (question.description != null) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = question.description), - style = MaterialTheme.typography.caption, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp, start = 8.dp, end = 8.dp) - ) - } - } - when (question.answer) { - is PossibleAnswer.SingleChoice -> SingleChoiceQuestion( - possibleAnswer = question.answer, - answer = answer as Answer.SingleChoice?, - onAnswerSelected = { answer -> onAnswer(Answer.SingleChoice(answer)) }, - modifier = Modifier.fillMaxWidth() - ) - is PossibleAnswer.MultipleChoice -> MultipleChoiceQuestion( - possibleAnswer = question.answer, - answer = answer as Answer.MultipleChoice?, - onAnswerSelected = { newAnswer, selected -> - // create the answer if it doesn't exist or - // update it based on the user's selection - if (answer == null) { - onAnswer(Answer.MultipleChoice(setOf(newAnswer))) - } else { - onAnswer(answer.withAnswerSelected(newAnswer, selected)) - } - }, - modifier = Modifier.fillMaxWidth() - ) - is PossibleAnswer.Action -> ActionQuestion( - questionId = question.id, - possibleAnswer = question.answer, - answer = answer as Answer.Action?, - onAction = onAction, - modifier = Modifier.fillMaxWidth() - ) - is PossibleAnswer.Slider -> SliderQuestion( - possibleAnswer = question.answer, - answer = answer as Answer.Slider?, - onAnswerSelected = { onAnswer(Answer.Slider(it)) }, - modifier = Modifier.fillMaxWidth() - ) - } - } - } -} - -@Composable -private fun SingleChoiceQuestion( - possibleAnswer: PossibleAnswer.SingleChoice, - answer: Answer.SingleChoice?, - onAnswerSelected: (Int) -> Unit, - modifier: Modifier = Modifier -) { - val options = possibleAnswer.optionsStringRes.associateBy { stringResource(id = it) } - - val radioOptions = options.keys.toList() - - val selected = if (answer != null) { - stringResource(id = answer.answer) - } else { - null - } - - val (selectedOption, onOptionSelected) = remember(answer) { mutableStateOf(selected) } - - Column(modifier = modifier) { - radioOptions.forEach { text -> - val onClickHandle = { - onOptionSelected(text) - options[text]?.let { onAnswerSelected(it) } - Unit - } - val optionSelected = text == selectedOption - Surface( - shape = MaterialTheme.shapes.small, - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) - ), - modifier = Modifier.padding(vertical = 8.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = optionSelected, - onClick = onClickHandle - ) - .padding(vertical = 16.dp, horizontal = 24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = text - ) - - RadioButton( - selected = optionSelected, - onClick = onClickHandle, - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colors.primary - ) - ) - } - } - } - } -} - -@Composable -private fun MultipleChoiceQuestion( - possibleAnswer: PossibleAnswer.MultipleChoice, - answer: Answer.MultipleChoice?, - onAnswerSelected: (Int, Boolean) -> Unit, - modifier: Modifier = Modifier -) { - val options = possibleAnswer.optionsStringRes.associateBy { stringResource(id = it) } - Column(modifier = modifier) { - for (option in options) { - var checkedState by remember(answer) { - val selectedOption = answer?.answersStringRes?.contains(option.value) - mutableStateOf(selectedOption ?: false) - } - Surface( - shape = MaterialTheme.shapes.small, - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) - ), - modifier = Modifier.padding(vertical = 4.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - onClick = { - checkedState = !checkedState - onAnswerSelected(option.value, checkedState) - } - ) - .padding(vertical = 16.dp, horizontal = 24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = option.key) - - Checkbox( - checked = checkedState, - onCheckedChange = { selected -> - checkedState = selected - onAnswerSelected(option.value, selected) - }, - colors = CheckboxDefaults.colors( - checkedColor = MaterialTheme.colors.primary - ), - ) - } - } - } - } -} - -@Composable -private fun ActionQuestion( - questionId: Int, - possibleAnswer: PossibleAnswer.Action, - answer: Answer.Action?, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier -) { - when (possibleAnswer.actionType) { - SurveyActionType.PICK_DATE -> { - DateQuestion( - questionId = questionId, - answerLabel = possibleAnswer.label, - answer = answer, - onAction = onAction, - modifier = modifier - ) - } - SurveyActionType.TAKE_PHOTO -> { - PhotoQuestion( - questionId = questionId, - answer = answer, - onAction = onAction, - modifier = modifier - ) - } - SurveyActionType.SELECT_CONTACT -> TODO() - } -} - -@Composable -private fun PhotoQuestion( - questionId: Int, - answer: Answer.Action?, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier -) { - val resource = if (answer != null) { - Icons.Filled.SwapHoriz - } else { - Icons.Filled.AddAPhoto - } - OutlinedButton( - onClick = { onAction(questionId, SurveyActionType.TAKE_PHOTO) }, - modifier = modifier, - contentPadding = PaddingValues() - ) { - Column { - if (answer != null && answer.result is SurveyActionResult.Photo) { - CoilImage( - data = answer.result.uri, - modifier = Modifier.fillMaxSize(), - fadeIn = true, - contentDescription = null - ) - } else { - PhotoDefaultImage(modifier = Modifier.padding(horizontal = 86.dp, vertical = 74.dp)) - } - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentSize(Alignment.BottomCenter) - .padding(vertical = 26.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = resource, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource( - id = if (answer != null) { - R.string.retake_photo - } else { - R.string.add_photo - } - ) - ) - } - } - } -} - -@Composable -private fun DateQuestion( - questionId: Int, - @StringRes answerLabel: Int, - answer: Answer.Action?, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier -) { - Button( - onClick = { onAction(questionId, SurveyActionType.PICK_DATE) }, - modifier = modifier.padding(vertical = 20.dp) - ) { - Text(text = stringResource(id = answerLabel)) - } - - if (answer != null && answer.result is SurveyActionResult.Date) { - Text( - text = stringResource(R.string.selected_date, answer.result.date), - style = MaterialTheme.typography.h4, - modifier = Modifier.padding(vertical = 20.dp) - ) - } -} - -@Composable -private fun PhotoDefaultImage( - modifier: Modifier = Modifier, - lightTheme: Boolean = MaterialTheme.colors.isLight -) { - val assetId = if (lightTheme) { - R.drawable.ic_selfie_light - } else { - R.drawable.ic_selfie_dark - } - Image( - painter = painterResource(id = assetId), - modifier = modifier, - contentDescription = null - ) -} - -@Composable -private fun SliderQuestion( - possibleAnswer: PossibleAnswer.Slider, - answer: Answer.Slider?, - onAnswerSelected: (Float) -> Unit, - modifier: Modifier = Modifier -) { - var sliderPosition by remember { - mutableStateOf(answer?.answerValue ?: possibleAnswer.defaultValue) - } - Row(modifier = modifier) { - Text( - text = stringResource(id = possibleAnswer.startText), - modifier = Modifier.align(Alignment.CenterVertically) - ) - Slider( - value = sliderPosition, - onValueChange = { - sliderPosition = it - onAnswerSelected(it) - }, - valueRange = possibleAnswer.range, - steps = possibleAnswer.steps, - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp) - ) - Text( - text = stringResource(id = possibleAnswer.endText), - modifier = Modifier.align(Alignment.CenterVertically) - ) - } -} - -@Preview -@Composable -fun QuestionPreview() { - val question = Question( - id = 2, - questionText = R.string.pick_superhero, - answer = PossibleAnswer.SingleChoice( - optionsStringRes = listOf( - R.string.spiderman, - R.string.ironman, - R.string.unikitty, - R.string.captain_planet - ) - ), - description = R.string.select_one - ) - JetsurveyTheme { - Question(question = question, answer = null, onAnswer = {}, onAction = { _, _ -> }) - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt deleted file mode 100644 index c5e314866b..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.os.Build -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.survey.PossibleAnswer.Action -import com.example.compose.jetsurvey.survey.PossibleAnswer.MultipleChoice -import com.example.compose.jetsurvey.survey.PossibleAnswer.SingleChoice -import com.example.compose.jetsurvey.survey.SurveyActionType.PICK_DATE -import com.example.compose.jetsurvey.survey.SurveyActionType.TAKE_PHOTO - -// Static data of questions -private val jetpackQuestions = mutableListOf( - Question( - id = 1, - questionText = R.string.in_my_free_time, - answer = MultipleChoice( - optionsStringRes = listOf( - R.string.read, - R.string.work_out, - R.string.draw, - R.string.play_games, - R.string.dance, - R.string.watch_movies - ) - ), - description = R.string.select_all - ), - Question( - id = 2, - questionText = R.string.pick_superhero, - answer = SingleChoice( - optionsStringRes = listOf( - R.string.spiderman, - R.string.ironman, - R.string.unikitty, - R.string.captain_planet - ) - ), - description = R.string.select_one - ), - Question( - id = 7, - questionText = R.string.favourite_movie, - answer = SingleChoice( - listOf( - R.string.star_trek, - R.string.social_network, - R.string.back_to_future, - R.string.outbreak - ) - ), - description = R.string.select_one - ), - Question( - id = 3, - questionText = R.string.takeaway, - answer = Action(label = R.string.pick_date, actionType = PICK_DATE), - description = R.string.select_date - ), - Question( - id = 4, - questionText = R.string.selfies, - answer = PossibleAnswer.Slider( - range = 1f..10f, - steps = 3, - startText = R.string.selfie_min, - endText = R.string.selfie_max - ) - ) -).apply { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { - // Add the camera feature only for devices 29+ - add( - Question( - id = 975, - questionText = R.string.selfie_skills, - answer = Action(label = R.string.add_photo, actionType = TAKE_PHOTO) - ) - ) - } -}.toList() - -private val jetpackSurvey = Survey( - title = R.string.which_jetpack_library, - questions = jetpackQuestions -) - -object SurveyRepository { - - suspend fun getSurvey() = jetpackSurvey - - @Suppress("UNUSED_PARAMETER") - fun getSurveyResult(answers: List>): SurveyResult { - return SurveyResult( - library = "Compose", - result = R.string.survey_result, - description = R.string.survey_result_description - ) - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt deleted file mode 100644 index a1ee63e264..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -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.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.Button -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -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.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.progressIndicatorBackground - -@Composable -fun SurveyQuestionsScreen( - questions: SurveyState.Questions, - onAction: (Int, SurveyActionType) -> Unit, - onDonePressed: () -> Unit, - onBackPressed: () -> Unit -) { - val questionState = remember(questions.currentQuestionIndex) { - questions.questionsState[questions.currentQuestionIndex] - } - - Surface(modifier = Modifier.fillMaxSize()) { - Scaffold( - topBar = { - SurveyTopAppBar( - questionIndex = questionState.questionIndex, - totalQuestionsCount = questionState.totalQuestionsCount, - onBackPressed = onBackPressed - ) - }, - content = { innerPadding -> - Question( - question = questionState.question, - answer = questionState.answer, - onAnswer = { - questionState.answer = it - questionState.enableNext = true - }, - onAction = onAction, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) - }, - bottomBar = { - SurveyBottomBar( - questionState = questionState, - onPreviousPressed = { questions.currentQuestionIndex-- }, - onNextPressed = { questions.currentQuestionIndex++ }, - onDonePressed = onDonePressed - ) - } - ) - } -} - -@Composable -fun SurveyResultScreen( - result: SurveyState.Result, - onDonePressed: () -> Unit -) { - Surface(modifier = Modifier.fillMaxSize()) { - Scaffold( - content = { innerPadding -> - val modifier = Modifier.padding(innerPadding) - SurveyResult(result = result, modifier = modifier) - }, - bottomBar = { - OutlinedButton( - onClick = { onDonePressed() }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 24.dp) - ) { - Text(text = stringResource(id = R.string.done)) - } - } - ) - } -} - -@Composable -private fun SurveyResult(result: SurveyState.Result, modifier: Modifier = Modifier) { - LazyColumn(modifier = modifier.fillMaxSize()) { - item { - Spacer(modifier = Modifier.height(44.dp)) - Text( - text = result.surveyResult.library, - style = MaterialTheme.typography.h3, - modifier = Modifier.padding(horizontal = 20.dp) - ) - Text( - text = stringResource( - result.surveyResult.result, - result.surveyResult.library - ), - style = MaterialTheme.typography.subtitle1, - modifier = Modifier.padding(20.dp) - ) - Text( - text = stringResource(result.surveyResult.description), - style = MaterialTheme.typography.body1, - modifier = Modifier.padding(horizontal = 20.dp) - ) - } - } -} - -@Composable -private fun TopAppBarTitle( - questionIndex: Int, - totalQuestionsCount: Int, - modifier: Modifier = Modifier -) { - val indexStyle = MaterialTheme.typography.caption.toSpanStyle().copy( - fontWeight = FontWeight.Bold - ) - val totalStyle = MaterialTheme.typography.caption.toSpanStyle() - val text = buildAnnotatedString { - withStyle(style = indexStyle) { - append("${questionIndex + 1}") - } - withStyle(style = totalStyle) { - append(stringResource(R.string.question_count, totalQuestionsCount)) - } - } - Text( - text = text, - style = MaterialTheme.typography.caption, - modifier = modifier - ) -} - -@Composable -private fun SurveyTopAppBar( - questionIndex: Int, - totalQuestionsCount: Int, - onBackPressed: () -> Unit -) { - Column(modifier = Modifier.fillMaxWidth()) { - Box(modifier = Modifier.fillMaxWidth()) { - TopAppBarTitle( - questionIndex = questionIndex, - totalQuestionsCount = totalQuestionsCount, - modifier = Modifier - .padding(vertical = 20.dp) - .align(Alignment.Center) - ) - - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - IconButton( - onClick = onBackPressed, - modifier = Modifier.padding(horizontal = 12.dp) - ) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(id = R.string.close) - ) - } - } - } - LinearProgressIndicator( - progress = (questionIndex + 1) / totalQuestionsCount.toFloat(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - backgroundColor = MaterialTheme.colors.progressIndicatorBackground - ) - } -} - -@Composable -private fun SurveyBottomBar( - questionState: QuestionState, - onPreviousPressed: () -> Unit, - onNextPressed: () -> Unit, - onDonePressed: () -> Unit -) { - Surface( - elevation = 3.dp, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 20.dp) - ) { - if (questionState.showPrevious) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = onPreviousPressed - ) { - Text(text = stringResource(id = R.string.previous)) - } - Spacer(modifier = Modifier.width(16.dp)) - } - if (questionState.showDone) { - Button( - modifier = Modifier.weight(1f), - onClick = onDonePressed, - enabled = questionState.enableNext - ) { - Text(text = stringResource(id = R.string.done)) - } - } else { - Button( - modifier = Modifier.weight(1f), - onClick = onNextPressed, - enabled = questionState.enableNext - ) { - Text(text = stringResource(id = R.string.next)) - } - } - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt deleted file mode 100644 index b9b92ba40a..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import androidx.annotation.StringRes -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -@Stable -class QuestionState( - val question: Question, - val questionIndex: Int, - val totalQuestionsCount: Int, - val showPrevious: Boolean, - val showDone: Boolean -) { - var enableNext by mutableStateOf(false) - var answer by mutableStateOf?>(null) -} - -sealed class SurveyState { - data class Questions( - @StringRes val surveyTitle: Int, - val questionsState: List - ) : SurveyState() { - var currentQuestionIndex by mutableStateOf(0) - } - - data class Result( - @StringRes val surveyTitle: Int, - val surveyResult: SurveyResult - ) : SurveyState() -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt deleted file mode 100644 index f5e86aa747..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.net.Uri -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch - -class SurveyViewModel( - private val surveyRepository: SurveyRepository, - private val photoUriManager: PhotoUriManager -) : ViewModel() { - - private val _uiState = MutableLiveData() - val uiState: LiveData - get() = _uiState - - private lateinit var surveyInitialState: SurveyState - - // Uri used to save photos taken with the camera - private var uri: Uri? = null - - init { - viewModelScope.launch { - val survey = surveyRepository.getSurvey() - - // Create the default questions state based on the survey questions - val questions: List = survey.questions.mapIndexed { index, question -> - val showPrevious = index > 0 - val showDone = index == survey.questions.size - 1 - QuestionState( - question = question, - questionIndex = index, - totalQuestionsCount = survey.questions.size, - showPrevious = showPrevious, - showDone = showDone - ) - } - surveyInitialState = SurveyState.Questions(survey.title, questions) - _uiState.value = surveyInitialState - } - } - - fun computeResult(surveyQuestions: SurveyState.Questions) { - val answers = surveyQuestions.questionsState.mapNotNull { it.answer } - val result = surveyRepository.getSurveyResult(answers) - _uiState.value = SurveyState.Result(surveyQuestions.surveyTitle, result) - } - - fun onDatePicked(questionId: Int, date: String) { - updateStateWithActionResult(questionId, SurveyActionResult.Date(date)) - } - - fun getUriToSaveImage(): Uri? { - uri = photoUriManager.buildNewUri() - return uri - } - - fun onImageSaved() { - uri?.let { uri -> - getLatestQuestionId()?.let { questionId -> - updateStateWithActionResult(questionId, SurveyActionResult.Photo(uri)) - } - } - } - - private fun updateStateWithActionResult(questionId: Int, result: SurveyActionResult) { - val latestState = _uiState.value - if (latestState != null && latestState is SurveyState.Questions) { - val question = - latestState.questionsState.first { questionState -> - questionState.question.id == questionId - } - question.answer = Answer.Action(result) - question.enableNext = true - } - } - - private fun getLatestQuestionId(): Int? { - val latestState = _uiState.value - if (latestState != null && latestState is SurveyState.Questions) { - return latestState.questionsState[latestState.currentQuestionIndex].question.id - } - return null - } -} - -class SurveyViewModelFactory( - private val photoUriManager: PhotoUriManager -) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(SurveyViewModel::class.java)) { - return SurveyViewModel(SurveyRepository, photoUriManager) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Color.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Color.kt deleted file mode 100644 index 5efe51c9c8..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Color.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.theme - -import androidx.compose.ui.graphics.Color - -val Purple300 = Color(0xFFCD52FC) -val Purple600 = Color(0xFF9F00F4) -val Purple700 = Color(0xFF8100EF) -val Purple800 = Color(0xFF0000E1) - -val Red300 = Color(0xFFD00036) -val Red800 = Color(0xFFEA6D7E) - -val Gray100 = Color(0xFFF5F5F5) -val Gray900 = Color(0xFF212121) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Shape.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Shape.kt deleted file mode 100644 index 62ee6482d6..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Shape.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -val Shapes = Shapes( - small = RoundedCornerShape(12.dp) -) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Theme.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Theme.kt deleted file mode 100644 index 2546ef3747..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Theme.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.theme - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -val LightThemeColors = lightColors( - primary = Purple700, - primaryVariant = Purple800, - onPrimary = Color.White, - secondary = Color.White, - onSecondary = Color.Black, - background = Color.White, - onBackground = Color.Black, - surface = Color.White, - onSurface = Color.Black, - error = Red800, - onError = Color.White -) - -val DarkThemeColors = darkColors( - primary = Purple300, - primaryVariant = Purple600, - onPrimary = Color.Black, - secondary = Color.Black, - onSecondary = Color.White, - background = Color.Black, - onBackground = Color.White, - surface = Color.Black, - onSurface = Color.White, - error = Red300, - onError = Color.Black -) - -val Colors.snackbarAction: Color - @Composable - get() = if (isLight) Purple300 else Purple700 - -val Colors.progressIndicatorBackground: Color - @Composable - get() = if (isLight) Color.Black.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.24f) - -@Composable -fun JetsurveyTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { - val colors = if (darkTheme) { - DarkThemeColors - } else { - LightThemeColors - } - MaterialTheme( - colors = colors, - typography = Typography, - shapes = Shapes, - content = content - ) -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Typography.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Typography.kt deleted file mode 100644 index b8a9943900..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Typography.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.theme - -import androidx.compose.material.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp -import com.example.compose.jetsurvey.R - -val MontserratFontFamily = FontFamily( - listOf( - Font(R.font.montserrat_regular), - Font(R.font.montserrat_medium, FontWeight.Medium), - Font(R.font.montserrat_semibold, FontWeight.SemiBold) - ) -) - -val Typography = Typography( - defaultFontFamily = MontserratFontFamily, - h1 = TextStyle( - fontWeight = FontWeight.W300, - fontSize = 96.sp, - letterSpacing = (-1.5).sp - ), - h2 = TextStyle( - fontWeight = FontWeight.W300, - fontSize = 60.sp, - letterSpacing = (-0.5).sp - ), - h3 = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 48.sp, - letterSpacing = 0.sp - ), - h4 = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 30.sp, - letterSpacing = 0.sp - ), - h5 = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 24.sp, - letterSpacing = 0.sp - ), - h6 = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 20.sp, - letterSpacing = 0.sp - ), - subtitle1 = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 16.sp, - letterSpacing = 0.15.sp - ), - subtitle2 = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 14.sp, - letterSpacing = 0.1.sp - ), - body1 = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 16.sp, - letterSpacing = 0.5.sp - ), - body2 = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 14.sp, - letterSpacing = 0.25.sp - ), - button = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 14.sp, - letterSpacing = 0.25.sp - ), - caption = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 12.sp, - letterSpacing = 0.4.sp - ), - overline = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 12.sp, - letterSpacing = 1.sp - ) -) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/util/Event.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/util/Event.kt deleted file mode 100644 index ebf6dc0164..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/util/Event.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.util - -/** - * Used as a wrapper for data that is exposed via a LiveData that represents an event. - */ -data class Event(private val content: T) { - - var hasBeenHandled = false - private set // Allow external read but not write - - /** - * Returns the content and prevents its use again. - */ - fun getContentIfNotHandled(): T? { - return if (hasBeenHandled) { - null - } else { - hasBeenHandled = true - content - } - } - - /** - * Returns the content, even if it's already been handled. - */ - fun peekContent(): T = content -} diff --git a/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_background.xml b/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_background.xml deleted file mode 100644 index 05d4c0bbf7..0000000000 --- a/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_background.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_foreground.xml deleted file mode 100644 index 86f401cca4..0000000000 --- a/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_foreground.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/Jetsurvey/app/src/main/res/drawable/ic_logo_dark.xml b/Jetsurvey/app/src/main/res/drawable/ic_logo_dark.xml deleted file mode 100644 index d81c9ccf49..0000000000 --- a/Jetsurvey/app/src/main/res/drawable/ic_logo_dark.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - diff --git a/Jetsurvey/app/src/main/res/drawable/ic_logo_light.xml b/Jetsurvey/app/src/main/res/drawable/ic_logo_light.xml deleted file mode 100644 index 826f755de6..0000000000 --- a/Jetsurvey/app/src/main/res/drawable/ic_logo_light.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - diff --git a/Jetsurvey/app/src/main/res/drawable/ic_selfie_dark.xml b/Jetsurvey/app/src/main/res/drawable/ic_selfie_dark.xml deleted file mode 100644 index 504306467e..0000000000 --- a/Jetsurvey/app/src/main/res/drawable/ic_selfie_dark.xml +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Jetsurvey/app/src/main/res/drawable/ic_selfie_light.xml b/Jetsurvey/app/src/main/res/drawable/ic_selfie_light.xml deleted file mode 100644 index bd7c7fa611..0000000000 --- a/Jetsurvey/app/src/main/res/drawable/ic_selfie_light.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Jetsurvey/app/src/main/res/font/montserrat_medium.ttf b/Jetsurvey/app/src/main/res/font/montserrat_medium.ttf deleted file mode 100755 index 6e079f6984..0000000000 Binary files a/Jetsurvey/app/src/main/res/font/montserrat_medium.ttf and /dev/null differ diff --git a/Jetsurvey/app/src/main/res/font/montserrat_regular.ttf b/Jetsurvey/app/src/main/res/font/montserrat_regular.ttf deleted file mode 100755 index 8d443d5d56..0000000000 Binary files a/Jetsurvey/app/src/main/res/font/montserrat_regular.ttf and /dev/null differ diff --git a/Jetsurvey/app/src/main/res/font/montserrat_semibold.ttf b/Jetsurvey/app/src/main/res/font/montserrat_semibold.ttf deleted file mode 100755 index f8a43f2b20..0000000000 Binary files a/Jetsurvey/app/src/main/res/font/montserrat_semibold.ttf and /dev/null differ diff --git a/Jetsurvey/app/src/main/res/layout/activity_main.xml b/Jetsurvey/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index d75ae2da63..0000000000 --- a/Jetsurvey/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/Jetsurvey/app/src/main/res/layout/content_main.xml b/Jetsurvey/app/src/main/res/layout/content_main.xml deleted file mode 100644 index 18500a4aae..0000000000 --- a/Jetsurvey/app/src/main/res/layout/content_main.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - diff --git a/Jetsurvey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetsurvey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2a520286f9..0000000000 --- a/Jetsurvey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - diff --git a/Jetsurvey/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Jetsurvey/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 1adb5a343f..0000000000 Binary files a/Jetsurvey/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Jetsurvey/app/src/main/res/navigation/nav_graph.xml b/Jetsurvey/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index 0f3eb7df82..0000000000 --- a/Jetsurvey/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/Jetsurvey/app/src/main/res/values-night/colors.xml b/Jetsurvey/app/src/main/res/values-night/colors.xml deleted file mode 100644 index affcce749d..0000000000 --- a/Jetsurvey/app/src/main/res/values-night/colors.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - #000000 - diff --git a/Jetsurvey/app/src/main/res/values/colors.xml b/Jetsurvey/app/src/main/res/values/colors.xml deleted file mode 100644 index 4a9aadb241..0000000000 --- a/Jetsurvey/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - #EFEFEF - diff --git a/Jetsurvey/app/src/main/res/values/ids.xml b/Jetsurvey/app/src/main/res/values/ids.xml deleted file mode 100644 index 9d6aec630f..0000000000 --- a/Jetsurvey/app/src/main/res/values/ids.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/Jetsurvey/app/src/main/res/values/strings.xml b/Jetsurvey/app/src/main/res/values/strings.xml deleted file mode 100644 index 50ea2ff2a5..0000000000 --- a/Jetsurvey/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,95 +0,0 @@ - - - Jetsurvey - Better surveys with Jetpack Compose - Email - Password - Confirm password - Sign in - SIGN IN - SIGN IN AS GUEST - Sign in or create an account - or - FORGOT PASSWORD? - CONTINUE - CREATE ACCOUNT - By continuing, you agree to our Terms of Service. We’ll - handle your data according to our Privacy Policy. - Feature not available - DISMISS - NEXT - PREVIOUS - DONE - Back - Close - Show password - Hide password - - - Which Jetpack library are you? - \u00A0of %d - Select one. - Select all that apply. - Select date. - - - In my free time I like to … - Read - Work out - Draw - Play video games - Dance - Watch movies - - - Pick a superhero - Spider man (Avengers) - Iron man (Avengers) - Uni-kitty (Lego Movie) - Captain Planet - - - When was the last time you ordered takeaway because you couldn\'t be bothered to cook? - Pick a date - 🥡 📅 %s - - - How do you feel about selfies 🤳? - 😒️ - 🤩️ - - - Show off your selfie skills! - ADD PHOTO - RETAKE PHOTO - - - Who\'s your best friend? - Pick a contact - - - What\'s your favourite movie? - Star Trek - The social network - Back to the future - Outbreak - - Congratulations, you are %s - You are a curious developer, always willing to try - something new. You want to stay up to date with the trends to Compose is your middle name - diff --git a/Jetsurvey/app/src/main/res/values/themes.xml b/Jetsurvey/app/src/main/res/values/themes.xml deleted file mode 100644 index d9d8e56ede..0000000000 --- a/Jetsurvey/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/Jetsurvey/build.gradle b/Jetsurvey/build.gradle deleted file mode 100644 index 8f92297fb2..0000000000 --- a/Jetsurvey/build.gradle +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.compose.jetsurvey.buildsrc.Libs -import com.example.compose.jetsurvey.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.10.0' -} - -subprojects { - repositories { - google() - mavenCentral() - jcenter() - - if (Libs.AndroidX.Compose.version.endsWith('SNAPSHOT')) { - maven { url Libs.AndroidX.Compose.snapshotUrl } - maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - - ktlint(Versions.ktlint).userData([android: "true"]) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - - // Enable experimental coroutines APIs, including Flow - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' - freeCompilerArgs += '-Xopt-in=kotlin.Experimental' - - // Set JVM target to 1.8 - jvmTarget = "1.8" - } - } - // Forcing to use the snapshot version - configurations.configureEach { - resolutionStrategy.eachDependency { details -> - if (details.requested.group.startsWith('androidx.compose')) { - details.useVersion Libs.AndroidX.Compose.version - } - } - } -} diff --git a/Jetsurvey/buildSrc/build.gradle.kts b/Jetsurvey/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Jetsurvey/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Jetsurvey/buildSrc/src/main/java/com/example/compose/jetsurvey/buildsrc/dependencies.kt b/Jetsurvey/buildSrc/src/main/java/com/example/compose/jetsurvey/buildsrc/dependencies.kt deleted file mode 100644 index bb1f093184..0000000000 --- a/Jetsurvey/buildSrc/src/main/java/com/example/compose/jetsurvey/buildsrc/dependencies.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.buildsrc - -object Versions { - const val ktlint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha11" - const val jdkDesugar = "com.android.tools:desugar_jdk_libs:1.0.9" - - const val junit = "junit:junit:4.13" - - const val material = "com.google.android.material:material:1.1.0" - - object Accompanist { - private const val version = "0.7.0" - const val coil = "com.google.accompanist:accompanist-coil:$version" - } - - object Kotlin { - private const val version = "1.4.31" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.1" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object AndroidX { - const val appcompat = "androidx.appcompat:appcompat:1.3.0-beta01" - const val coreKtx = "androidx.core:core-ktx:1.5.0-beta03" - - object Lifecycle { - private const val version = "2.3.0-beta01" - const val viewModelCompose = "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha03" - const val viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - } - - object Activity { - const val activityCompose = "androidx.activity:activity-compose:1.3.0-alpha05" - } - - object Compose { - const val snapshot = "" - const val version = "1.0.0-beta03" - - @get:JvmStatic - val snapshotUrl: String - get() = "https://androidx.dev/snapshots/builds/$snapshot/artifacts/repository/" - - const val foundation = "androidx.compose.foundation:foundation:$version" - const val layout = "androidx.compose.foundation:foundation-layout:$version" - const val material = "androidx.compose.material:material:$version" - const val materialIconsExtended = "androidx.compose.material:material-icons-extended:$version" - const val runtime = "androidx.compose.runtime:runtime:$version" - const val runtimeLivedata = "androidx.compose.runtime:runtime-livedata:$version" - const val tooling = "androidx.compose.ui:ui-tooling:$version" - const val test = "androidx.compose.test:test-core:$version" - const val uiTest = "androidx.compose.ui:ui-test:$version" - } - - object Navigation { - private const val version = "2.3.0" - const val fragment = "androidx.navigation:navigation-fragment-ktx:$version" - const val uiKtx = "androidx.navigation:navigation-ui-ktx:$version" - } - - object Material { - private const val version = "1.2.0" - const val material = "com.google.android.material:material:$version" - } - - object Test { - private const val version = "1.2.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - } -} diff --git a/Jetsurvey/debug.keystore b/Jetsurvey/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Jetsurvey/debug.keystore and /dev/null differ diff --git a/Jetsurvey/gradle.properties b/Jetsurvey/gradle.properties deleted file mode 100644 index b2d834ce9c..0000000000 --- a/Jetsurvey/gradle.properties +++ /dev/null @@ -1,42 +0,0 @@ -# -# Copyright 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m - -# Turn on parallel compilation, caching and on-demand configuration -org.gradle.configureondemand=true -org.gradle.caching=true -org.gradle.parallel=true - -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true - -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Jetsurvey/gradle/wrapper/gradle-wrapper.jar b/Jetsurvey/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e708b1c023..0000000000 Binary files a/Jetsurvey/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/Jetsurvey/gradle/wrapper/gradle-wrapper.properties b/Jetsurvey/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2a563242c1..0000000000 --- a/Jetsurvey/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/Jetsurvey/gradlew b/Jetsurvey/gradlew deleted file mode 100755 index 4f906e0c81..0000000000 --- a/Jetsurvey/gradlew +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env sh - -# -# Copyright 2015 the original author or authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -exec "$JAVACMD" "$@" diff --git a/Jetsurvey/gradlew.bat b/Jetsurvey/gradlew.bat deleted file mode 100644 index ac1b06f938..0000000000 --- a/Jetsurvey/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/Jetsurvey/screenshots/dark_signin.png b/Jetsurvey/screenshots/dark_signin.png deleted file mode 100644 index 7d3857712b..0000000000 Binary files a/Jetsurvey/screenshots/dark_signin.png and /dev/null differ diff --git a/Jetsurvey/screenshots/light_signin.png b/Jetsurvey/screenshots/light_signin.png deleted file mode 100644 index 60c4aad0ec..0000000000 Binary files a/Jetsurvey/screenshots/light_signin.png and /dev/null differ diff --git a/Jetsurvey/screenshots/signup_error.png b/Jetsurvey/screenshots/signup_error.png deleted file mode 100644 index 0b4133751e..0000000000 Binary files a/Jetsurvey/screenshots/signup_error.png and /dev/null differ diff --git a/Jetsurvey/screenshots/survey.gif b/Jetsurvey/screenshots/survey.gif deleted file mode 100644 index af2911ca97..0000000000 Binary files a/Jetsurvey/screenshots/survey.gif and /dev/null differ diff --git a/Jetsurvey/screenshots/welcome.png b/Jetsurvey/screenshots/welcome.png deleted file mode 100644 index f96aaf2f93..0000000000 Binary files a/Jetsurvey/screenshots/welcome.png and /dev/null differ diff --git a/Jetsurvey/settings.gradle b/Jetsurvey/settings.gradle deleted file mode 100644 index 347c7f3faa..0000000000 --- a/Jetsurvey/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -include ':app' -rootProject.name = "Jetsurvey" diff --git a/LICENSE b/LICENSE index 96198aca8f..79b3dd5a8e 100644 --- a/LICENSE +++ b/LICENSE @@ -174,7 +174,18 @@ END OF TERMS AND CONDITIONS - Copyright 2020 The Android Open Source Project + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -186,4 +197,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. + limitations under the License. \ No newline at end of file diff --git a/Owl/.gitignore b/Owl/.gitignore deleted file mode 100644 index 3d02999faf..0000000000 --- a/Owl/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -# Gradle -.gradle -build/ - -captures - -/local.properties - -# IntelliJ .idea folder -/.idea -*.iml - -# General -.DS_Store -.externalNativeBuild diff --git a/Owl/.google/packaging.yaml b/Owl/.google/packaging.yaml deleted file mode 100644 index 9d138a236b..0000000000 --- a/Owl/.google/packaging.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (C) 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# GOOGLE SAMPLE PACKAGING DATA -# -# This file is used by Google as part of our samples packaging process. -# End users may safely ignore this file. It has no relevance to other systems. ---- -status: PUBLISHED -technologies: [Android] -categories: [Compose] -languages: [Kotlin] -solutions: [Mobile] -github: android/compose-samples -level: INTERMEDIATE -apiRefs: - - android:androidx.compose.Composable -license: apache2 diff --git a/Owl/README.md b/Owl/README.md deleted file mode 100644 index e98cf5c45a..0000000000 --- a/Owl/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Owl sample - -This sample is a [Jetpack Compose][compose] implementation of [Owl][owl], a Material Design study. - -To try out these sample apps, you need to use the latest Canary version of Android Studio 4.2. -You can clone this repository or import the -project from Android Studio following the steps -[here](https://developer.android.com/jetpack/compose/setup#sample). - -This sample showcases: - -* [Material theming][materialtheming] & light/dark themes -* Custom layout -* Animation - -## Screenshots - - - -## Features - -#### [Onboarding Screen](app/src/main/java/com/example/owl/ui/onboarding) -The onboarding screen allows users to customize their experience by selecting topics. Notable features: -* Custom [staggered grid layout](app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt#L239). -* [Topic chip](app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt#L171) with custom [selection animation](app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt#L157). - -#### [Courses Screen](app/src/main/java/com/example/owl/ui/courses) -The courses screen displays featured and saved course and a search screen. Notable fetures: -* Custom [`StaggeredVerticalGrid`](app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt#L161) responsive to available size. -* [`FeaturedCourse`](app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt#L70) composable demonstrates usage of [`ConstraintLayout`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/package-summary.html#ConstraintLayout(androidx.compose.ui.Modifier,%20kotlin.Function1)). - -#### [Course Details Screen](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt) -Displays details of a selected course, featuring: - -* A [FloatingActionButton](https://material.io/components/buttons-floating-action-button) that can be clicked or dragged to transform into a [`LessonsSheet`](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt#L309). -* A selection of [`RelatedCourses`](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt#L262) using a nested `BlueTheme`. - -#### [Theming](app/src/main/java/com/example/owl/ui/theme) -Owl follows Material Design, customizing [colors](app/src/main/java/com/example/owl/ui/theme/Color.kt), [typography](app/src/main/java/com/example/owl/ui/theme/Type.kt) and [shapes](app/src/main/java/com/example/owl/ui/theme/Shape.kt). These come together in Owl's multiple [themes](app/src/main/java/com/example/owl/ui/theme/Theme.kt), one for each color scheme. Additionaly, Owl supports [image](app/src/main/java/com/example/owl/ui/theme/Images.kt) and [elevation](app/src/main/java/com/example/owl/ui/theme/Elevation.kt) theming, providing alternate images/elevations in light/dark themes. - -#### [Common UI](app/src/main/java/com/example/owl/ui/common) -Compose makes it simple to create a library of components and use them throughout the app. See: -* [`CourseListItem`](app/src/main/java/com/example/owl/ui/common/CourseListItem.kt) is used on both the [My Courses](app/src/main/java/com/example/owl/ui/courses/MyCourses.kt) screen and in the related section of the [Course Details](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt) screen. -* [`OutlinedAvatar`](app/src/main/java/com/example/owl/ui/common/OutlinedAvatar.kt) is used on both the [Featured Courses](app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt) screen and the [Course Details](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt) screen. - -#### [Utilities](app/src/main/java/com/example/owl/ui/utils/) -Owl implements some utility functions of interest: -* [Window insets](https://goo.gle/compose-insets) will likely be provided by the Compose library at some point. Until then this demonstrates how it can be implemented. -* [Navigation](app/src/main/java/com/example/owl/ui/utils/Navigation.kt): an implementation of [Android Architecture Components Navigation](https://developer.android.com/guide/navigation) will be provided for Compose at some point. Until then this class provides a simple [`Navigator`](app/src/main/java/com/example/owl/ui/utils/Navigation.kt#L32) with back-stack and a [`backHandler`](app/src/main/java/com/example/owl/ui/utils/Navigation.kt#L79) effect. - -## Data -Domain types are modelled in the [model package](app/src/main/java/com/example/owl/model), each containing static sample data exposed using fake `Repo`s objects. - -Imagery is sourced from [Unsplash](https://unsplash.com/) and [Pravatar](https://pravatar.cc/) and loaded using [coil-accompanist][coil-accompanist]. - - -## License -``` -Copyright 2020 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` - -[compose]: https://developer.android.com/jetpack/compose -[owl]: https://material.io/design/material-studies/owl.html -[materialtheming]: https://material.io/design/material-theming/overview.html#material-theming -[coil-accompanist]: https://google.github.io/accompanist/coil/ diff --git a/Owl/app/.gitignore b/Owl/app/.gitignore deleted file mode 100644 index 796b96d1c4..0000000000 --- a/Owl/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/Owl/app/build.gradle b/Owl/app/build.gradle deleted file mode 100644 index 5f6a16aba2..0000000000 --- a/Owl/app/build.gradle +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.owl.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' -} - -android { - compileSdkVersion 30 - defaultConfig { - applicationId 'com.example.owl' - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName '1.0' - vectorDrawables.useSupportLibrary true - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - buildFeatures { - compose true - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } - - packagingOptions { - exclude "META-INF/licenses/**" - exclude "META-INF/AL2.0" - exclude "META-INF/LGPL2.1" - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - implementation Libs.AndroidX.coreKtx - implementation Libs.AndroidX.navigation - implementation Libs.AndroidX.Activity.activityCompose - implementation Libs.AndroidX.ConstraintLayout.constraintLayoutCompose - - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.foundation - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.ui - implementation Libs.AndroidX.Compose.uiUtil - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.animation - implementation Libs.AndroidX.Compose.iconsExtended - implementation Libs.AndroidX.Compose.tooling - - implementation Libs.Accompanist.coil - implementation Libs.Accompanist.insets - - androidTestImplementation Libs.AndroidX.Activity.activityCompose - - androidTestImplementation Libs.JUnit.junit - androidTestImplementation Libs.AndroidX.Test.core - androidTestImplementation Libs.AndroidX.Test.espressoCore - androidTestImplementation Libs.AndroidX.Test.rules - androidTestImplementation Libs.AndroidX.Test.Ext.junit - androidTestImplementation Libs.AndroidX.Compose.uiTest -} diff --git a/Owl/app/proguard-rules.pro b/Owl/app/proguard-rules.pro deleted file mode 100644 index 4cb94585a0..0000000000 --- a/Owl/app/proguard-rules.pro +++ /dev/null @@ -1,24 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile - -# Repackage classes into the top-level. --repackageclasses diff --git a/Owl/app/src/androidTest/java/com/example/owl/ui/NavigationTest.kt b/Owl/app/src/androidTest/java/com/example/owl/ui/NavigationTest.kt deleted file mode 100644 index 1d461cbb36..0000000000 --- a/Owl/app/src/androidTest/java/com/example/owl/ui/NavigationTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui - -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import com.example.owl.R -import com.example.owl.model.courses -import com.example.owl.ui.fakes.ProvideTestImageLoader -import com.example.owl.ui.utils.LocalBackDispatcher -import com.google.accompanist.insets.ProvideWindowInsets -import org.junit.Rule -import org.junit.Test - -/** - * Checks that the navigation flows in the app are correct. - */ -class NavigationTest { - - /** - * Using an empty activity to have control of the content that is set. - * - * This activity must be declared in the manifest (see src/debug/AndroidManifest.xml) - */ - @get:Rule - val composeTestRule = createAndroidComposeRule() - - private fun startActivity(startDestination: String? = null) { - composeTestRule.setContent { - val backDispatcher = composeTestRule.activity.onBackPressedDispatcher - CompositionLocalProvider(LocalBackDispatcher provides backDispatcher) { - ProvideWindowInsets { - ProvideTestImageLoader { - if (startDestination == null) { - NavGraph() - } else { - NavGraph(startDestination) - } - } - } - } - } - } - - @Test - fun firstScreenIsOnboarding() { - // When the app is open - startActivity() - // The first screen should be the onboarding screen. - // Assert that the FAB label for the onboarding screen exists: - composeTestRule.onNodeWithContentDescription(getOnboardingFabLabel()).assertExists() - } - - @Test - fun onboardingToCourses() { - // Given the app in the onboarding screen - startActivity() - - // Navigate to the next screen by clicking on the FAB - val fabLabel = getOnboardingFabLabel() - composeTestRule.onNodeWithContentDescription(fabLabel).performClick() - - // The first course should be shown - composeTestRule.onNodeWithText( - text = courses.first().name, - substring = true - ).assertExists() - } - - @Test - fun coursesToDetail() { - // Given the app in the courses screen - startActivity(MainDestinations.COURSES_ROUTE) - - // Navigate to the first course - composeTestRule.onNode( - hasContentDescription(getFeaturedCourseLabel()).and( - hasText( - text = courses.first().name, - substring = true - ) - ) - ).performClick() - - // Assert navigated to the course details - composeTestRule.onNodeWithText( - text = getCourseDesc().take(15), - substring = true - ).assertExists() - } - - @Test - fun coursesToDetailAndBack() { - coursesToDetail() - composeTestRule.runOnUiThread { - composeTestRule.activity.onBackPressed() - } - - // The first course should be shown - composeTestRule.onNodeWithText( - text = courses.first().name, - substring = true - ).assertExists() - } - - private fun getOnboardingFabLabel(): String { - return composeTestRule.activity.resources.getString(R.string.label_continue_to_courses) - } - - private fun getFeaturedCourseLabel(): String { - return composeTestRule.activity.resources.getString(R.string.featured) - } - - private fun getCourseDesc(): String { - return composeTestRule.activity.resources.getString(R.string.course_desc) - } -} diff --git a/Owl/app/src/androidTest/java/com/example/owl/ui/fakes/ProvideTestImageLoader.kt b/Owl/app/src/androidTest/java/com/example/owl/ui/fakes/ProvideTestImageLoader.kt deleted file mode 100644 index ddb8e4966d..0000000000 --- a/Owl/app/src/androidTest/java/com/example/owl/ui/fakes/ProvideTestImageLoader.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.fakes - -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import coil.ImageLoader -import coil.annotation.ExperimentalCoilApi -import coil.bitmap.BitmapPool -import coil.decode.DataSource -import coil.memory.MemoryCache -import coil.request.DefaultRequestOptions -import coil.request.Disposable -import coil.request.ImageRequest -import coil.request.ImageResult -import coil.request.SuccessResult -import com.google.accompanist.coil.LocalImageLoader - -/** - * Replaces all remote images with a simple black drawable to make testing faster and hermetic. - */ -@OptIn(ExperimentalCoilApi::class) -@Composable -fun ProvideTestImageLoader(content: @Composable () -> Unit) { - - // From https://coil-kt.github.io/coil/image_loaders/ - val loader = object : ImageLoader { - private val drawable = ColorDrawable(Color.BLACK) - - private val disposable = object : Disposable { - override val isDisposed get() = true - override fun dispose() {} - override suspend fun await() {} - } - - override val bitmapPool: BitmapPool = BitmapPool(0) - - override val defaults: DefaultRequestOptions = DefaultRequestOptions() - override val memoryCache: MemoryCache - get() = TODO("Not yet implemented") - - override fun enqueue(request: ImageRequest): Disposable { - // Always call onStart before onSuccess. - request.target?.onStart(drawable) - request.target?.onSuccess(drawable) - return disposable - } - - override suspend fun execute(request: ImageRequest): ImageResult { - return SuccessResult( - drawable = drawable, - request = request, - metadata = ImageResult.Metadata( - memoryCacheKey = MemoryCache.Key(""), - isSampled = false, - dataSource = DataSource.MEMORY_CACHE, - isPlaceholderMemoryCacheKeyPresent = false - ) - ) - } - - override fun shutdown() {} - } - CompositionLocalProvider(LocalImageLoader provides loader, content = content) -} diff --git a/Owl/app/src/debug/AndroidManifest.xml b/Owl/app/src/debug/AndroidManifest.xml deleted file mode 100644 index aa21dd579b..0000000000 --- a/Owl/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/Owl/app/src/main/AndroidManifest.xml b/Owl/app/src/main/AndroidManifest.xml deleted file mode 100644 index 855b3b5422..0000000000 --- a/Owl/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/Owl/app/src/main/java/com/example/owl/model/Course.kt b/Owl/app/src/main/java/com/example/owl/model/Course.kt deleted file mode 100644 index 85f3dc498f..0000000000 --- a/Owl/app/src/main/java/com/example/owl/model/Course.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.model - -import androidx.compose.runtime.Immutable - -@Immutable // Tell Compose runtime that this object will not change so it can perform optimizations -data class Course( - val id: Long, - val name: String, - val subject: String, - val thumbUrl: String, - val thumbContentDesc: String, - val description: String = "", - val steps: Int, - val step: Int, - val instructor: String = "https://i.pravatar.cc/112?$id" -) - -/** - * A fake repo - */ -object CourseRepo { - fun getCourse(courseId: Long): Course = courses.find { it.id == courseId }!! - fun getRelated(@Suppress("UNUSED_PARAMETER") courseId: Long): List = courses -} - -val courses = listOf( - Course( - id = 0, - name = "Basic Blocks and Woodturning", - subject = "Arts & Crafts", - thumbUrl = "https://images.unsplash.com/photo-1516562309708-05f3b2b2c238", - thumbContentDesc = "", - steps = 7, - step = 1 - ), - Course( - id = 1, - name = "An Introduction To Oil Painting On Canvas", - subject = "Painting", - thumbUrl = "https://images.unsplash.com/photo-1508261301902-79a2d8e78f71", - thumbContentDesc = "", - steps = 12, - step = 1 - ), - Course( - id = 2, - name = "Understanding the Composition of Modern Cities", - subject = "Architecture", - thumbUrl = "https://images.unsplash.com/photo-1519999482648-25049ddd37b1", - thumbContentDesc = "", - steps = 18, - step = 1 - ), - Course( - id = 3, - name = "Learning The Basics of Brand Identity", - subject = "Design", - thumbUrl = "https://images.unsplash.com/photo-1517602302552-471fe67acf66", - thumbContentDesc = "", - steps = 22, - step = 1 - ), - Course( - id = 4, - name = "Wooden Materials and Sculpting Machinery", - subject = "Arts & Crafts", - thumbUrl = "https://images.unsplash.com/photo-1547609434-b732edfee020", - thumbContentDesc = "", - steps = 19, - step = 1 - ), - Course( - id = 5, - name = "Advanced Potter's Wheel", - subject = "Arts & Crafts", - thumbUrl = "https://images.unsplash.com/photo-1513096082106-f68f05c8c21c", - thumbContentDesc = "", - steps = 14, - step = 1 - ), - Course( - id = 6, - name = "Advanced Abstract Shapes & 3D Printing", - subject = "Arts & Crafts", - thumbUrl = "https://images.unsplash.com/photo-1461887046916-c7426e65460d", - thumbContentDesc = "", - steps = 17, - step = 1 - ), - Course( - id = 7, - name = "Beginning Portraiture", - subject = "Photography", - thumbUrl = "https://images.unsplash.com/photo-1555940451-2480c214446f", - thumbContentDesc = "", - steps = 22, - step = 1 - ), - Course( - id = 8, - name = "Intermediate Knife Skills", - subject = "Culinary", - thumbUrl = "https://images.unsplash.com/photo-1544965838-54ef8406f868", - thumbContentDesc = "", - steps = 14, - step = 1 - ), - Course( - id = 9, - name = "Pattern Making for Beginners", - subject = "Fashion", - thumbUrl = "https://images.unsplash.com/photo-1552737894-aae873ee2737", - thumbContentDesc = "", - steps = 7, - step = 1 - ), - Course( - id = 10, - name = "Location Lighting for Beginners", - subject = "Photography", - thumbUrl = "https://images.unsplash.com/photo-1554941829-202a0b2403b8", - thumbContentDesc = "", - steps = 6, - step = 1 - ), - Course( - id = 11, - name = "Cinematography & Lighting", - subject = "Film", - thumbUrl = "https://images.unsplash.com/photo-1517523267857-911eef21acae", - thumbContentDesc = "", - steps = 4, - step = 1 - ), - Course( - id = 12, - name = "Monuments, Buildings & Other Structures", - subject = "Photography", - thumbUrl = "https://images.unsplash.com/photo-1494145904049-0dca59b4bbad", - thumbContentDesc = "", - steps = 4, - step = 1 - ) -) diff --git a/Owl/app/src/main/java/com/example/owl/model/Lesson.kt b/Owl/app/src/main/java/com/example/owl/model/Lesson.kt deleted file mode 100644 index 8bba647582..0000000000 --- a/Owl/app/src/main/java/com/example/owl/model/Lesson.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.model - -import androidx.compose.runtime.Immutable - -@Immutable -data class Lesson( - val title: String, - val formattedStepNumber: String, - val length: String, - val imageUrl: String, - val imageContentDescription: String = "" -) - -/** - * A fake repo - */ -object LessonsRepo { - fun getLessons(@Suppress("UNUSED_PARAMETER") courseId: Long) = lessons -} - -val lessons = listOf( - Lesson( - title = "An introduction to the Landscape", - formattedStepNumber = "01", - length = "4:14", - imageUrl = "https://images.unsplash.com/photo-1506744038136-46273834b3fb" - ), - Lesson( - title = "Movement and Expression", - formattedStepNumber = "02", - length = "7:28", - imageUrl = "https://images.unsplash.com/photo-1511715282680-fbf93a50e721" - ), - Lesson( - title = "Composition and the Urban Canvas", - formattedStepNumber = "03", - length = "3:43", - imageUrl = "https://images.unsplash.com/photo-1494616150024-f6040d5220c0" - ), - Lesson( - title = "Lighting Techniques and Aesthetics", - formattedStepNumber = "04", - length = "4:45", - imageUrl = "https://images.unsplash.com/photo-1544980944-0bf2ec0063ef" - ), - Lesson( - title = "Special Effects", - formattedStepNumber = "05", - length = "6:19", - imageUrl = "https://images.unsplash.com/photo-1508521049563-61d4bb00b270" - ), - Lesson( - title = "Techniques with Structures", - formattedStepNumber = "06", - length = "9:41", - imageUrl = "https://images.unsplash.com/photo-1479839672679-a46483c0e7c8" - ), - Lesson( - title = "Deep Focus Using a Camera Dolly", - formattedStepNumber = "07", - length = "4:43", - imageUrl = "https://images.unsplash.com/photo-1495854245347-f3936493f799" - ), - Lesson( - title = "Point of View Shots with Structures", - formattedStepNumber = "08", - length = "9:41", - imageUrl = "https://images.unsplash.com/photo-1534971710649-2f97e5f98bc4" - ), - Lesson( - title = "Photojournalism: Street Art", - formattedStepNumber = "09", - length = "9:41", - imageUrl = "https://images.unsplash.com/photo-1453814235491-3cfac3999928" - ) -) diff --git a/Owl/app/src/main/java/com/example/owl/model/Topic.kt b/Owl/app/src/main/java/com/example/owl/model/Topic.kt deleted file mode 100644 index 7cd0f0f94e..0000000000 --- a/Owl/app/src/main/java/com/example/owl/model/Topic.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.model - -import androidx.compose.runtime.Immutable - -@Immutable -data class Topic( - val name: String, - val courses: Int, - val imageUrl: String -) - -val topics = listOf( - Topic("Architecture", 58, "https://images.unsplash.com/photo-1479839672679-a46483c0e7c8"), - Topic("Arts & Crafts", 121, "https://images.unsplash.com/photo-1422246358533-95dcd3d48961"), - Topic("Business", 78, "https://images.unsplash.com/photo-1507679799987-c73779587ccf"), - Topic("Culinary", 118, "https://images.unsplash.com/photo-1551218808-94e220e084d2"), - Topic("Design", 423, "https://images.unsplash.com/photo-1493932484895-752d1471eab5"), - Topic("Fashion", 92, "https://images.unsplash.com/photo-1517840545241-b491010a8af4"), - Topic("Film", 165, "https://images.unsplash.com/photo-1518676590629-3dcbd9c5a5c9"), - Topic("Gaming", 164, "https://images.unsplash.com/photo-1528870884180-5649b20f6435"), - Topic("Illustration", 326, "https://images.unsplash.com/photo-1526312426976-f4d754fa9bd6"), - Topic("Lifestyle", 305, "https://images.unsplash.com/photo-1471560090527-d1af5e4e6eb6"), - Topic("Music", 212, "https://images.unsplash.com/photo-1454922915609-78549ad709bb"), - Topic("Painting", 172, "https://images.unsplash.com/photo-1461344577544-4e5dc9487184"), - Topic("Photography", 321, "https://images.unsplash.com/photo-1542567455-cd733f23fbb1"), - Topic("Technology", 118, "https://images.unsplash.com/photo-1535223289827-42f1e9919769") -) diff --git a/Owl/app/src/main/java/com/example/owl/ui/MainActivity.kt b/Owl/app/src/main/java/com/example/owl/ui/MainActivity.kt deleted file mode 100644 index 81bdf2d156..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/MainActivity.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.core.view.WindowCompat - -class MainActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // This app draws behind the system bars, so we want to handle fitting system windows - WindowCompat.setDecorFitsSystemWindows(window, false) - - setContent { - OwlApp(onBackPressedDispatcher) - } - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/NavGraph.kt b/Owl/app/src/main/java/com/example/owl/ui/NavGraph.kt deleted file mode 100644 index 04038c3f4f..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/NavGraph.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.navArgument -import androidx.navigation.compose.navigate -import androidx.navigation.compose.rememberNavController -import com.example.owl.ui.MainDestinations.COURSE_DETAIL_ID_KEY -import com.example.owl.ui.course.CourseDetails -import com.example.owl.ui.courses.Courses -import com.example.owl.ui.onboarding.Onboarding - -/** - * Destinations used in the ([OwlApp]). - */ -object MainDestinations { - const val ONBOARDING_ROUTE = "onboarding" - const val COURSES_ROUTE = "courses" - const val COURSE_DETAIL_ROUTE = "course" - const val COURSE_DETAIL_ID_KEY = "courseId" -} - -@Composable -fun NavGraph(startDestination: String = MainDestinations.ONBOARDING_ROUTE) { - val navController = rememberNavController() - - val actions = remember(navController) { MainActions(navController) } - NavHost( - navController = navController, - startDestination = startDestination - ) { - composable(MainDestinations.ONBOARDING_ROUTE) { - Onboarding(onboardingComplete = actions.onboardingComplete) - } - composable(MainDestinations.COURSES_ROUTE) { - Courses(selectCourse = actions.selectCourse) - } - composable( - "${MainDestinations.COURSE_DETAIL_ROUTE}/{$COURSE_DETAIL_ID_KEY}", - arguments = listOf(navArgument(COURSE_DETAIL_ID_KEY) { type = NavType.LongType }) - ) { backStackEntry -> - val arguments = requireNotNull(backStackEntry.arguments) - CourseDetails( - courseId = arguments.getLong(COURSE_DETAIL_ID_KEY), - selectCourse = actions.selectCourse, - upPress = actions.upPress - ) - } - } -} - -/** - * Models the navigation actions in the app. - */ -class MainActions(navController: NavHostController) { - val onboardingComplete: () -> Unit = { - navController.navigate(MainDestinations.COURSES_ROUTE) - } - val selectCourse: (Long) -> Unit = { courseId: Long -> - navController.navigate("${MainDestinations.COURSE_DETAIL_ROUTE}/$courseId") - } - val upPress: () -> Unit = { - navController.navigateUp() - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/OwlApp.kt b/Owl/app/src/main/java/com/example/owl/ui/OwlApp.kt deleted file mode 100644 index ef47a5cba0..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/OwlApp.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui - -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import com.example.owl.ui.utils.LocalBackDispatcher -import com.example.owl.ui.utils.ProvideImageLoader -import com.google.accompanist.insets.ProvideWindowInsets - -@Composable -fun OwlApp(backDispatcher: OnBackPressedDispatcher) { - - CompositionLocalProvider(LocalBackDispatcher provides backDispatcher) { - ProvideWindowInsets { - ProvideImageLoader { - NavGraph() - } - } - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/common/CourseListItem.kt b/Owl/app/src/main/java/com/example/owl/ui/common/CourseListItem.kt deleted file mode 100644 index cabf73702a..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/common/CourseListItem.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.common - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.OndemandVideo -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.Course -import com.example.owl.model.courses -import com.example.owl.ui.theme.BlueTheme -import com.example.owl.ui.theme.OwlTheme -import com.example.owl.ui.utils.NetworkImage - -@Composable -fun CourseListItem( - course: Course, - onClick: () -> Unit, - modifier: Modifier = Modifier, - shape: Shape = RectangleShape, - elevation: Dp = OwlTheme.elevations.card, - titleStyle: TextStyle = MaterialTheme.typography.subtitle1, - iconSize: Dp = 16.dp -) { - Surface( - elevation = elevation, - shape = shape, - modifier = modifier - ) { - Row(modifier = Modifier.clickable(onClick = onClick)) { - NetworkImage( - url = course.thumbUrl, - contentDescription = null, - modifier = Modifier.aspectRatio(1f) - ) - Column( - modifier = Modifier.padding( - start = 16.dp, - top = 16.dp, - end = 16.dp, - bottom = 8.dp - ) - ) { - Text( - text = course.name, - style = titleStyle, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - .padding(bottom = 4.dp) - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Rounded.OndemandVideo, - tint = MaterialTheme.colors.primary, - contentDescription = null, - modifier = Modifier.size(iconSize) - ) - Text( - text = stringResource( - R.string.course_step_steps, - course.step, - course.steps - ), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.caption, - modifier = Modifier - .padding(start = 8.dp) - .weight(1f) - .wrapContentWidth(Alignment.Start) - ) - NetworkImage( - url = course.instructor, - contentDescription = null, - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - ) - } - } - } - } -} - -@Preview(name = "Course list item") -@Composable -private fun CourseListItemPreviewLight() { - CourseListItemPreview(false) -} - -@Preview(name = "Course list item – Dark") -@Composable -private fun CourseListItemPreviewDark() { - CourseListItemPreview(true) -} - -@Composable -private fun CourseListItemPreview(darkTheme: Boolean) { - BlueTheme(darkTheme) { - CourseListItem( - course = courses.first(), - onClick = {} - ) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/common/OutlinedAvatar.kt b/Owl/app/src/main/java/com/example/owl/ui/common/OutlinedAvatar.kt deleted file mode 100644 index 8f8012ff08..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/common/OutlinedAvatar.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.common - -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.owl.ui.theme.BlueTheme -import com.example.owl.ui.utils.NetworkImage - -@Composable -fun OutlinedAvatar( - url: String, - modifier: Modifier = Modifier, - outlineSize: Dp = 3.dp, - outlineColor: Color = MaterialTheme.colors.surface -) { - Box( - modifier = modifier.background( - color = outlineColor, - shape = CircleShape - ) - ) { - NetworkImage( - url = url, - contentDescription = null, - modifier = Modifier - .padding(outlineSize) - .fillMaxSize() - .clip(CircleShape) - ) - } -} - -@Preview( - name = "Outlined Avatar", - widthDp = 40, - heightDp = 40 -) -@Composable -private fun OutlinedAvatarPreview() { - BlueTheme { - OutlinedAvatar(url = "") - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/course/CourseDetails.kt b/Owl/app/src/main/java/com/example/owl/ui/course/CourseDetails.kt deleted file mode 100644 index 2394031201..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/course/CourseDetails.kt +++ /dev/null @@ -1,563 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.course - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.Orientation.Vertical -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FractionalThreshold -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.contentColorFor -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.material.icons.rounded.PlayCircleOutline -import androidx.compose.material.icons.rounded.PlaylistPlay -import androidx.compose.material.primarySurface -import androidx.compose.material.rememberSwipeableState -import androidx.compose.material.swipeable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -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.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.Course -import com.example.owl.model.CourseRepo -import com.example.owl.model.Lesson -import com.example.owl.model.LessonsRepo -import com.example.owl.model.courses -import com.example.owl.ui.common.CourseListItem -import com.example.owl.ui.common.OutlinedAvatar -import com.example.owl.ui.theme.BlueTheme -import com.example.owl.ui.theme.PinkTheme -import com.example.owl.ui.theme.pink500 -import com.example.owl.ui.utils.NetworkImage -import com.example.owl.ui.utils.backHandler -import com.example.owl.ui.utils.lerp -import com.example.owl.ui.utils.scrim -import com.google.accompanist.insets.LocalWindowInsets -import com.google.accompanist.insets.navigationBarsPadding -import com.google.accompanist.insets.statusBarsPadding -import com.google.accompanist.insets.toPaddingValues -import kotlinx.coroutines.launch - -private val FabSize = 56.dp -private const val ExpandedSheetAlpha = 0.96f - -@Composable -fun CourseDetails( - courseId: Long, - selectCourse: (Long) -> Unit, - upPress: () -> Unit -) { - // Simplified for the sample - val course = remember(courseId) { CourseRepo.getCourse(courseId) } - // TODO: Show error if course not found. - CourseDetails(course, selectCourse, upPress) -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun CourseDetails( - course: Course, - selectCourse: (Long) -> Unit, - upPress: () -> Unit -) { - PinkTheme { - BoxWithConstraints { - val sheetState = rememberSwipeableState(SheetState.Closed) - val fabSize = with(LocalDensity.current) { FabSize.toPx() } - val dragRange = constraints.maxHeight - fabSize - val scope = rememberCoroutineScope() - - backHandler( - enabled = sheetState.currentValue == SheetState.Open, - onBack = { - scope.launch { - sheetState.animateTo(SheetState.Closed) - } - } - ) - - Box( - // The Lessons sheet is initially closed and appears as a FAB. Make it openable by - // swiping or clicking the FAB. - Modifier.swipeable( - state = sheetState, - anchors = mapOf( - 0f to SheetState.Closed, - -dragRange to SheetState.Open - ), - thresholds = { _, _ -> FractionalThreshold(0.5f) }, - orientation = Vertical - ) - ) { - val openFraction = if (sheetState.offset.value.isNaN()) { - 0f - } else { - -sheetState.offset.value / dragRange - }.coerceIn(0f, 1f) - CourseDescription(course, selectCourse, upPress) - LessonsSheet( - course, - openFraction, - this@BoxWithConstraints.constraints.maxWidth.toFloat(), - this@BoxWithConstraints.constraints.maxHeight.toFloat() - ) { state -> - scope.launch { - sheetState.animateTo(state) - } - } - } - } - } -} - -@Composable -private fun CourseDescription( - course: Course, - selectCourse: (Long) -> Unit, - upPress: () -> Unit -) { - Surface(modifier = Modifier.fillMaxSize()) { - LazyColumn { - item { CourseDescriptionHeader(course, upPress) } - item { CourseDescriptionBody(course) } - item { RelatedCourses(course.id, selectCourse) } - } - } -} - -@Composable -private fun CourseDescriptionHeader( - course: Course, - upPress: () -> Unit -) { - Box { - NetworkImage( - url = course.thumbUrl, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .scrim(colors = listOf(Color(0x80000000), Color(0x33000000))) - .aspectRatio(4f / 3f) - ) - TopAppBar( - backgroundColor = Color.Transparent, - elevation = 0.dp, - contentColor = Color.White, // always white as image has dark scrim - modifier = Modifier.statusBarsPadding() - ) { - IconButton(onClick = upPress) { - Icon( - imageVector = Icons.Rounded.ArrowBack, - contentDescription = stringResource(R.string.label_back) - ) - } - Image( - painter = painterResource(id = R.drawable.ic_logo), - contentDescription = null, - modifier = Modifier - .padding(bottom = 4.dp) - .size(24.dp) - .align(Alignment.CenterVertically) - ) - Spacer(modifier = Modifier.weight(1f)) - } - OutlinedAvatar( - url = course.instructor, - modifier = Modifier - .size(40.dp) - .align(Alignment.BottomCenter) - .offset(y = 20.dp) // overlap bottom of image - ) - } -} - -@Composable -private fun CourseDescriptionBody(course: Course) { - Text( - text = course.subject.toUpperCase(), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.body2, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding( - start = 16.dp, - top = 36.dp, - end = 16.dp, - bottom = 16.dp - ) - ) - Text( - text = course.name, - style = MaterialTheme.typography.h4, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(16.dp)) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.course_desc), - style = MaterialTheme.typography.body1, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) - } - Divider(modifier = Modifier.padding(16.dp)) - Text( - text = stringResource(id = R.string.what_you_ll_need), - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.needs), - style = MaterialTheme.typography.body1, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding( - start = 16.dp, - top = 16.dp, - end = 16.dp, - bottom = 32.dp - ) - ) - } -} - -@Composable -private fun RelatedCourses( - courseId: Long, - selectCourse: (Long) -> Unit -) { - val relatedCourses = remember(courseId) { CourseRepo.getRelated(courseId) } - BlueTheme { - Surface( - color = MaterialTheme.colors.primarySurface, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.navigationBarsPadding()) { - Text( - text = stringResource(id = R.string.you_ll_also_like), - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 16.dp, - vertical = 24.dp - ) - ) - LazyRow( - contentPadding = PaddingValues( - start = 16.dp, - bottom = 32.dp, - end = FabSize + 8.dp - ) - ) { - items(relatedCourses) { related -> - CourseListItem( - course = related, - onClick = { selectCourse(related.id) }, - titleStyle = MaterialTheme.typography.body2, - modifier = Modifier - .padding(end = 8.dp) - .size(288.dp, 80.dp), - iconSize = 14.dp - ) - } - } - } - } - } -} - -@Composable -private fun LessonsSheet( - course: Course, - openFraction: Float, - width: Float, - height: Float, - updateSheet: (SheetState) -> Unit -) { - // Use the fraction that the sheet is open to drive the transformation from FAB -> Sheet - val fabSize = with(LocalDensity.current) { FabSize.toPx() } - val fabSheetHeight = fabSize + LocalWindowInsets.current.systemBars.bottom - val offsetX = lerp(width - fabSize, 0f, 0f, 0.15f, openFraction) - val offsetY = lerp(height - fabSheetHeight, 0f, openFraction) - val tlCorner = lerp(fabSize, 0f, 0f, 0.15f, openFraction) - val surfaceColor = lerp( - startColor = pink500, - endColor = MaterialTheme.colors.primarySurface.copy(alpha = ExpandedSheetAlpha), - startFraction = 0f, - endFraction = 0.3f, - fraction = openFraction - ) - Surface( - color = surfaceColor, - contentColor = contentColorFor(backgroundColor = MaterialTheme.colors.primarySurface), - shape = RoundedCornerShape(topStart = tlCorner), - modifier = Modifier.graphicsLayer { - translationX = offsetX - translationY = offsetY - } - ) { - Lessons(course, openFraction, surfaceColor, updateSheet) - } -} - -@Composable -private fun Lessons( - course: Course, - openFraction: Float, - surfaceColor: Color = MaterialTheme.colors.surface, - updateSheet: (SheetState) -> Unit -) { - val lessons: List = remember(course.id) { LessonsRepo.getLessons(course.id) } - - Box(modifier = Modifier.fillMaxWidth()) { - // When sheet open, show a list of the lessons - val lessonsAlpha = lerp(0f, 1f, 0.2f, 0.8f, openFraction) - Column( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = lessonsAlpha } - .statusBarsPadding() - ) { - val scroll = rememberLazyListState() - val appBarElevation by animateDpAsState(if (scroll.isScrolled) 4.dp else 0.dp) - val appBarColor = if (appBarElevation > 0.dp) surfaceColor else Color.Transparent - TopAppBar( - backgroundColor = appBarColor, - elevation = appBarElevation - ) { - Text( - text = course.name, - style = MaterialTheme.typography.subtitle1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(16.dp) - .weight(1f) - .align(Alignment.CenterVertically) - ) - IconButton( - onClick = { updateSheet(SheetState.Closed) }, - modifier = Modifier.align(Alignment.CenterVertically) - ) { - Icon( - imageVector = Icons.Rounded.ExpandMore, - contentDescription = stringResource(R.string.label_collapse_lessons) - ) - } - } - LazyColumn( - state = scroll, - contentPadding = LocalWindowInsets.current.systemBars.toPaddingValues( - top = false - ) - ) { - items(lessons) { lesson -> - Lesson(lesson) - Divider(startIndent = 128.dp) - } - } - } - - // When sheet closed, show the FAB - val fabAlpha = lerp(1f, 0f, 0f, 0.15f, openFraction) - Box( - modifier = Modifier - .size(FabSize) - .padding(start = 16.dp, top = 8.dp) // visually center contents - .graphicsLayer { alpha = fabAlpha } - ) { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { updateSheet(SheetState.Open) } - ) { - Icon( - imageVector = Icons.Rounded.PlaylistPlay, - tint = MaterialTheme.colors.onPrimary, - contentDescription = stringResource(R.string.label_expand_lessons) - ) - } - } - } -} - -@Composable -private fun Lesson(lesson: Lesson) { - Row( - modifier = Modifier - .clickable(onClick = { /* todo */ }) - .padding(vertical = 16.dp) - ) { - NetworkImage( - url = lesson.imageUrl, - contentDescription = null, - modifier = Modifier.size(112.dp, 64.dp) - ) - Column( - modifier = Modifier - .weight(1f) - .padding(start = 16.dp) - ) { - Text( - text = lesson.title, - style = MaterialTheme.typography.subtitle2, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Row( - modifier = Modifier.padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.PlayCircleOutline, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Text( - modifier = Modifier.padding(start = 4.dp), - text = lesson.length, - style = MaterialTheme.typography.caption - ) - } - } - } - Text( - text = lesson.formattedStepNumber, - style = MaterialTheme.typography.subtitle2, - modifier = Modifier.padding(horizontal = 16.dp) - ) - } -} - -private enum class SheetState { Open, Closed } - -private val LazyListState.isScrolled: Boolean - get() = firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0 - -@Preview(name = "Course Details") -@Composable -private fun CourseDetailsPreview() { - val courseId = courses.first().id - CourseDetails( - courseId = courseId, - selectCourse = { }, - upPress = { } - ) -} - -@Preview(name = "Lessons Sheet — Closed") -@Composable -private fun LessonsSheetClosedPreview() { - LessonsSheetPreview(0f) -} - -@Preview(name = "Lessons Sheet — Open") -@Composable -private fun LessonsSheetOpenPreview() { - LessonsSheetPreview(1f) -} - -@Preview(name = "Lessons Sheet — Open – Dark") -@Composable -private fun LessonsSheetOpenDarkPreview() { - LessonsSheetPreview(1f, true) -} - -@Composable -private fun LessonsSheetPreview( - openFraction: Float, - darkTheme: Boolean = false -) { - PinkTheme(darkTheme) { - val color = MaterialTheme.colors.primarySurface - Surface(color = color) { - Lessons( - course = courses.first(), - openFraction = openFraction, - surfaceColor = color, - updateSheet = { } - ) - } - } -} - -@Preview(name = "Related") -@Composable -private fun RelatedCoursesPreview() { - val related = courses.random() - RelatedCourses( - courseId = related.id, - selectCourse = { } - ) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/courses/Courses.kt b/Owl/app/src/main/java/com/example/owl/ui/courses/Courses.kt deleted file mode 100644 index f9f2b968f2..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/courses/Courses.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.courses - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.BottomNavigation -import androidx.compose.material.BottomNavigationItem -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.primarySurface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.courses -import com.example.owl.model.topics -import com.example.owl.ui.theme.BlueTheme -import com.google.accompanist.insets.navigationBarsHeight -import com.google.accompanist.insets.navigationBarsPadding - -@Composable -fun Courses(selectCourse: (Long) -> Unit) { - BlueTheme { - val (selectedTab, setSelectedTab) = remember { mutableStateOf(CourseTabs.FEATURED) } - val tabs = CourseTabs.values() - Scaffold( - backgroundColor = MaterialTheme.colors.primarySurface, - bottomBar = { - BottomNavigation( - Modifier.navigationBarsHeight(additional = 56.dp) - ) { - tabs.forEach { tab -> - BottomNavigationItem( - icon = { Icon(painterResource(tab.icon), contentDescription = null) }, - label = { Text(stringResource(tab.title).toUpperCase()) }, - selected = tab == selectedTab, - onClick = { setSelectedTab(tab) }, - alwaysShowLabel = false, - selectedContentColor = MaterialTheme.colors.secondary, - unselectedContentColor = LocalContentColor.current, - modifier = Modifier.navigationBarsPadding() - ) - } - } - } - ) { innerPadding -> - val modifier = Modifier.padding(innerPadding) - when (selectedTab) { - CourseTabs.MY_COURSES -> MyCourses(courses, selectCourse, modifier) - CourseTabs.FEATURED -> FeaturedCourses(courses, selectCourse, modifier) - CourseTabs.SEARCH -> SearchCourses(topics, modifier) - } - } - } -} - -@Composable -fun CoursesAppBar() { - TopAppBar( - elevation = 0.dp, - modifier = Modifier.height(80.dp) - ) { - Image( - modifier = Modifier - .padding(16.dp) - .align(Alignment.CenterVertically), - painter = painterResource(id = R.drawable.ic_lockup_white), - contentDescription = null - ) - IconButton( - modifier = Modifier.align(Alignment.CenterVertically), - onClick = { /* todo */ } - ) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = stringResource(R.string.label_profile) - ) - } - } -} - -private enum class CourseTabs( - @StringRes val title: Int, - @DrawableRes val icon: Int -) { - MY_COURSES(R.string.my_courses, R.drawable.ic_grain), - FEATURED(R.string.featured, R.drawable.ic_featured), - SEARCH(R.string.search, R.drawable.ic_search) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt b/Owl/app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt deleted file mode 100644 index 5a85b276c4..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.courses - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.LocalElevationOverlay -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.OndemandVideo -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout -import com.example.owl.R -import com.example.owl.model.Course -import com.example.owl.model.courses -import com.example.owl.ui.common.OutlinedAvatar -import com.example.owl.ui.theme.BlueTheme -import com.example.owl.ui.theme.OwlTheme -import com.example.owl.ui.utils.NetworkImage -import com.google.accompanist.insets.statusBarsPadding -import kotlin.math.ceil - -@Composable -fun FeaturedCourses( - courses: List, - selectCourse: (Long) -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .verticalScroll(rememberScrollState()) - .statusBarsPadding() - ) { - CoursesAppBar() - StaggeredVerticalGrid( - maxColumnWidth = 220.dp, - modifier = Modifier.padding(4.dp) - ) { - courses.forEach { course -> - FeaturedCourse(course, selectCourse) - } - } - } -} - -@Composable -fun FeaturedCourse( - course: Course, - selectCourse: (Long) -> Unit, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier.padding(4.dp), - color = MaterialTheme.colors.surface, - elevation = OwlTheme.elevations.card, - shape = MaterialTheme.shapes.medium - ) { - val featuredString = stringResource(id = R.string.featured) - ConstraintLayout( - modifier = Modifier - .clickable( - onClick = { selectCourse(course.id) } - ) - .semantics { - contentDescription = featuredString - } - ) { - val (image, avatar, subject, name, steps, icon) = createRefs() - NetworkImage( - url = course.thumbUrl, - contentDescription = null, - modifier = Modifier - .aspectRatio(4f / 3f) - .constrainAs(image) { - centerHorizontallyTo(parent) - top.linkTo(parent.top) - } - ) - val outlineColor = LocalElevationOverlay.current?.apply( - color = MaterialTheme.colors.surface, - elevation = OwlTheme.elevations.card - ) ?: MaterialTheme.colors.surface - OutlinedAvatar( - url = course.instructor, - outlineColor = outlineColor, - modifier = Modifier - .size(38.dp) - .constrainAs(avatar) { - centerHorizontallyTo(parent) - centerAround(image.bottom) - } - ) - Text( - text = course.subject.toUpperCase(), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.overline, - modifier = Modifier - .padding(16.dp) - .constrainAs(subject) { - centerHorizontallyTo(parent) - top.linkTo(avatar.bottom) - } - ) - Text( - text = course.name, - style = MaterialTheme.typography.subtitle1, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(horizontal = 16.dp) - .constrainAs(name) { - centerHorizontallyTo(parent) - top.linkTo(subject.bottom) - } - ) - val center = createGuidelineFromStart(0.5f) - Icon( - imageVector = Icons.Rounded.OndemandVideo, - tint = MaterialTheme.colors.primary, - contentDescription = null, - modifier = Modifier - .size(16.dp) - .constrainAs(icon) { - end.linkTo(center) - centerVerticallyTo(steps) - } - ) - Text( - text = course.steps.toString(), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.subtitle2, - modifier = Modifier - .padding( - start = 4.dp, - top = 16.dp, - bottom = 16.dp - ) - .constrainAs(steps) { - start.linkTo(center) - top.linkTo(name.bottom) - } - ) - } - } -} - -@Composable -fun StaggeredVerticalGrid( - modifier: Modifier = Modifier, - maxColumnWidth: Dp, - content: @Composable () -> Unit -) { - Layout( - content = content, - modifier = modifier - ) { measurables, constraints -> - check(constraints.hasBoundedWidth) { - "Unbounded width not supported" - } - val columns = ceil(constraints.maxWidth / maxColumnWidth.toPx()).toInt() - val columnWidth = constraints.maxWidth / columns - val itemConstraints = constraints.copy(maxWidth = columnWidth) - val colHeights = IntArray(columns) { 0 } // track each column's height - val placeables = measurables.map { measurable -> - val column = shortestColumn(colHeights) - val placeable = measurable.measure(itemConstraints) - colHeights[column] += placeable.height - placeable - } - - val height = colHeights.maxOrNull()?.coerceIn(constraints.minHeight, constraints.maxHeight) - ?: constraints.minHeight - layout( - width = constraints.maxWidth, - height = height - ) { - val colY = IntArray(columns) { 0 } - placeables.forEach { placeable -> - val column = shortestColumn(colY) - placeable.place( - x = columnWidth * column, - y = colY[column] - ) - colY[column] += placeable.height - } - } - } -} - -private fun shortestColumn(colHeights: IntArray): Int { - var minHeight = Int.MAX_VALUE - var column = 0 - colHeights.forEachIndexed { index, height -> - if (height < minHeight) { - minHeight = height - column = index - } - } - return column -} - -@Preview(name = "Featured Course") -@Composable -private fun FeaturedCoursePreview() { - BlueTheme { - FeaturedCourse( - course = courses.first(), - selectCourse = { } - ) - } -} - -@Preview(name = "Featured Courses Portrait") -@Composable -private fun FeaturedCoursesPreview() { - BlueTheme { - FeaturedCourses( - courses = courses, - selectCourse = { } - ) - } -} - -@Preview(name = "Featured Courses Dark") -@Composable -private fun FeaturedCoursesPreviewDark() { - BlueTheme(darkTheme = true) { - FeaturedCourses( - courses = courses, - selectCourse = { } - ) - } -} - -@Preview( - name = "Featured Courses Landscape", - widthDp = 640, - heightDp = 360 -) -@Composable -private fun FeaturedCoursesPreviewLandscape() { - BlueTheme { - FeaturedCourses( - courses = courses, - selectCourse = { } - ) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/courses/MyCourses.kt b/Owl/app/src/main/java/com/example/owl/ui/courses/MyCourses.kt deleted file mode 100644 index 148927cec8..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/courses/MyCourses.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.courses - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.owl.model.Course -import com.example.owl.model.courses -import com.example.owl.ui.common.CourseListItem -import com.example.owl.ui.theme.BlueTheme -import com.google.accompanist.insets.statusBarsHeight - -@Composable -fun MyCourses( - courses: List, - selectCourse: (Long) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn(modifier) { - item { - Spacer(Modifier.statusBarsHeight()) - } - item { - CoursesAppBar() - } - itemsIndexed(courses) { index, course -> - MyCourse(course, index, selectCourse) - } - } -} - -@Composable -fun MyCourse( - course: Course, - index: Int, - selectCourse: (Long) -> Unit -) { - Row(modifier = Modifier.padding(bottom = 8.dp)) { - val stagger = if (index % 2 == 0) 72.dp else 16.dp - Spacer(modifier = Modifier.width(stagger)) - CourseListItem( - course = course, - onClick = { selectCourse(course.id) }, - shape = RoundedCornerShape(topStart = 24.dp), - modifier = Modifier.height(96.dp) - ) - } -} - -@Preview(name = "My Courses") -@Composable -private fun MyCoursesPreview() { - BlueTheme { - MyCourses( - courses = courses, - selectCourse = { } - ) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/courses/SearchCourses.kt b/Owl/app/src/main/java/com/example/owl/ui/courses/SearchCourses.kt deleted file mode 100644 index 38204f1a29..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/courses/SearchCourses.kt +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.courses - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentColor -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.Topic -import com.example.owl.model.topics -import com.example.owl.ui.theme.BlueTheme -import com.google.accompanist.insets.statusBarsPadding - -@Composable -fun SearchCourses( - topics: List, - modifier: Modifier = Modifier -) { - val (searchTerm, updateSearchTerm) = remember { mutableStateOf(TextFieldValue("")) } - LazyColumn(modifier = modifier.statusBarsPadding()) { - item { AppBar(searchTerm, updateSearchTerm) } - val filteredTopics = getTopics(searchTerm.text, topics) - items(filteredTopics) { topic -> - Text( - text = topic.name, - style = MaterialTheme.typography.h5, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = { /* todo */ }) - .padding( - start = 16.dp, - top = 8.dp, - end = 16.dp, - bottom = 8.dp - ) - .wrapContentWidth(Alignment.Start) - ) - } - } -} - -/** - * This logic should live outside UI, but full arch omitted for simplicity in this sample. - */ -private fun getTopics( - searchTerm: String, - topics: List -): List { - return if (searchTerm != "") { - topics.filter { it.name.contains(searchTerm, ignoreCase = true) } - } else { - topics - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun AppBar( - searchTerm: TextFieldValue, - updateSearchTerm: (TextFieldValue) -> Unit -) { - TopAppBar(elevation = 0.dp) { - Image( - painter = painterResource(id = R.drawable.ic_search), - contentDescription = null, - modifier = Modifier - .padding(16.dp) - .align(Alignment.CenterVertically) - ) - // TODO hint - BasicTextField( - value = searchTerm, - onValueChange = updateSearchTerm, - textStyle = MaterialTheme.typography.subtitle1.copy( - color = LocalContentColor.current - ), - maxLines = 1, - cursorBrush = SolidColor(LocalContentColor.current), - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) - IconButton( - modifier = Modifier.align(Alignment.CenterVertically), - onClick = { /* todo */ } - ) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = stringResource(R.string.label_profile) - ) - } - } -} - -@Preview(name = "Search Courses") -@Composable -private fun FeaturedCoursesPreview() { - BlueTheme { - SearchCourses(topics, Modifier) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt b/Owl/app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt deleted file mode 100644 index fac855e1b2..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt +++ /dev/null @@ -1,343 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.onboarding - -import androidx.compose.animation.core.animateDp -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.Image -import androidx.compose.foundation.horizontalScroll -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.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material.ContentAlpha -import androidx.compose.material.FloatingActionButton -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Done -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.rounded.Explore -import androidx.compose.material.primarySurface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.Topic -import com.example.owl.model.topics -import com.example.owl.ui.theme.OwlTheme -import com.example.owl.ui.theme.YellowTheme -import com.example.owl.ui.theme.pink500 -import com.example.owl.ui.utils.NetworkImage -import com.google.accompanist.insets.navigationBarsPadding -import com.google.accompanist.insets.statusBarsPadding -import kotlin.math.max - -@Composable -fun Onboarding(onboardingComplete: () -> Unit) { - YellowTheme { - Scaffold( - topBar = { AppBar() }, - backgroundColor = MaterialTheme.colors.primarySurface, - floatingActionButton = { - FloatingActionButton( - onClick = onboardingComplete, - modifier = Modifier - .navigationBarsPadding() - ) { - Icon( - imageVector = Icons.Rounded.Explore, - contentDescription = stringResource(R.string.label_continue_to_courses) - ) - } - } - ) { innerPadding -> - Column( - modifier = Modifier - .statusBarsPadding() - .navigationBarsPadding() - .padding(innerPadding) - ) { - Text( - text = stringResource(R.string.choose_topics_that_interest_you), - style = MaterialTheme.typography.h4, - textAlign = TextAlign.End, - modifier = Modifier.padding( - horizontal = 16.dp, - vertical = 32.dp - ) - ) - TopicsGrid( - modifier = Modifier - .weight(1f) - .wrapContentHeight() - ) - Spacer(Modifier.height(56.dp)) // center grid accounting for FAB - } - } - } -} - -@Composable -private fun AppBar() { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - ) { - Image( - painter = painterResource(id = OwlTheme.images.lockupLogo), - contentDescription = null, - modifier = Modifier.padding(16.dp) - ) - IconButton( - modifier = Modifier.padding(16.dp), - onClick = { /* todo */ } - ) { - Icon( - imageVector = Icons.Filled.Settings, - contentDescription = stringResource(R.string.label_settings) - ) - } - } -} - -@Composable -private fun TopicsGrid(modifier: Modifier = Modifier) { - StaggeredGrid( - modifier = modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 8.dp) - ) { - topics.forEach { topic -> - TopicChip(topic = topic) - } - } -} - -private enum class SelectionState { Unselected, Selected } - -/** - * Class holding animating values when transitioning topic chip states. - */ -private class TopicChipTransition( - cornerRadius: State, - selectedAlpha: State, - checkScale: State -) { - val cornerRadius by cornerRadius - val selectedAlpha by selectedAlpha - val checkScale by checkScale -} - -@Composable -private fun topicChipTransition(topicSelected: Boolean): TopicChipTransition { - val transition = updateTransition( - targetState = if (topicSelected) SelectionState.Selected else SelectionState.Unselected - ) - val corerRadius = transition.animateDp { state -> - when (state) { - SelectionState.Unselected -> 0.dp - SelectionState.Selected -> 28.dp - } - } - val selectedAlpha = transition.animateFloat { state -> - when (state) { - SelectionState.Unselected -> 0f - SelectionState.Selected -> 0.8f - } - } - val checkScale = transition.animateFloat { state -> - when (state) { - SelectionState.Unselected -> 0.6f - SelectionState.Selected -> 1f - } - } - return remember(transition) { - TopicChipTransition(corerRadius, selectedAlpha, checkScale) - } -} - -@Composable -private fun TopicChip(topic: Topic) { - val (selected, onSelected) = remember { mutableStateOf(false) } - val topicChipTransitionState = topicChipTransition(selected) - - Surface( - modifier = Modifier.padding(4.dp), - elevation = OwlTheme.elevations.card, - shape = MaterialTheme.shapes.medium.copy( - topStart = CornerSize( - topicChipTransitionState.cornerRadius - ) - ) - ) { - Row(modifier = Modifier.toggleable(value = selected, onValueChange = onSelected)) { - Box { - NetworkImage( - url = topic.imageUrl, - contentDescription = null, - modifier = Modifier - .size(width = 72.dp, height = 72.dp) - .aspectRatio(1f) - ) - if (topicChipTransitionState.selectedAlpha > 0f) { - Surface( - color = pink500.copy(alpha = topicChipTransitionState.selectedAlpha), - modifier = Modifier.matchParentSize() - ) { - Icon( - imageVector = Icons.Filled.Done, - contentDescription = null, - tint = MaterialTheme.colors.onPrimary.copy( - alpha = topicChipTransitionState.selectedAlpha - ), - modifier = Modifier - .wrapContentSize() - .scale(topicChipTransitionState.checkScale) - ) - } - } - } - Column { - Text( - text = topic.name, - style = MaterialTheme.typography.body1, - modifier = Modifier.padding( - start = 16.dp, - top = 16.dp, - end = 16.dp, - bottom = 8.dp - ) - ) - Row(verticalAlignment = Alignment.CenterVertically) { - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Icon( - painter = painterResource(R.drawable.ic_grain), - contentDescription = null, - modifier = Modifier - .padding(start = 16.dp) - .size(12.dp) - ) - Text( - text = topic.courses.toString(), - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(start = 8.dp) - ) - } - } - } - } - } -} - -@Composable -private fun StaggeredGrid( - modifier: Modifier = Modifier, - rows: Int = 3, - content: @Composable () -> Unit -) { - Layout( - content = content, - modifier = modifier - ) { measurables, constraints -> - val rowWidths = IntArray(rows) { 0 } // Keep track of the width of each row - val rowHeights = IntArray(rows) { 0 } // Keep track of the height of each row - - // Don't constrain child views further, measure them with given constraints - val placeables = measurables.mapIndexed { index, measurable -> - val placeable = measurable.measure(constraints) - - // Track the width and max height of each row - val row = index % rows - rowWidths[row] += placeable.width - rowHeights[row] = max(rowHeights[row], placeable.height) - - placeable - } - - // Grid's width is the widest row - val width = rowWidths.maxOrNull()?.coerceIn(constraints.minWidth, constraints.maxWidth) - ?: constraints.minWidth - // Grid's height is the sum of each row - val height = rowHeights.sum().coerceIn(constraints.minHeight, constraints.maxHeight) - - // y co-ord of each row - val rowY = IntArray(rows) { 0 } - for (i in 1 until rows) { - rowY[i] = rowY[i - 1] + rowHeights[i - 1] - } - layout(width, height) { - // x co-ord we have placed up to, per row - val rowX = IntArray(rows) { 0 } - placeables.forEachIndexed { index, placeable -> - val row = index % rows - placeable.place( - x = rowX[row], - y = rowY[row] - ) - rowX[row] += placeable.width - } - } - } -} - -@Preview(name = "Onboarding") -@Composable -private fun OnboardingPreview() { - Onboarding(onboardingComplete = { }) -} - -@Preview("Topic Chip") -@Composable -private fun TopicChipPreview() { - YellowTheme { - TopicChip(topics.first()) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Color.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Color.kt deleted file mode 100644 index 1d38c957b4..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Color.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.material.Colors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver - -val yellow200 = Color(0xffffeb46) -val yellow400 = Color(0xffffc000) -val yellow500 = Color(0xffffde03) -val yellowDarkPrimary = Color(0xff242316) - -val blue200 = Color(0xff91a4fc) -val blue700 = Color(0xff0336ff) -val blue800 = Color(0xff0035c9) -val blueDarkPrimary = Color(0xff1c1d24) - -val pink200 = Color(0xffff7597) -val pink500 = Color(0xffff0266) -val pink600 = Color(0xffd8004d) -val pinkDarkPrimary = Color(0xff24191c) - -/** - * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the - * given [alpha]. Useful for situations where semi-transparent colors are undesirable. - */ -@Composable -fun Colors.compositedOnSurface(alpha: Float): Color { - return onSurface.copy(alpha = alpha).compositeOver(surface) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Elevations.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Elevations.kt deleted file mode 100644 index b2a15c8ba7..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Elevations.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -/** - * Elevation values that can be themed. - */ -@Immutable -data class Elevations(val card: Dp = 0.dp) - -internal val LocalElevations = staticCompositionLocalOf { Elevations() } diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Images.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Images.kt deleted file mode 100644 index fa82bf0727..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Images.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.annotation.DrawableRes -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.staticCompositionLocalOf - -/** - * Images that can vary by theme. - */ -@Immutable -data class Images(@DrawableRes val lockupLogo: Int) - -internal val LocalImages = staticCompositionLocalOf { - error("No LocalImages specified") -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Shape.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Shape.kt deleted file mode 100644 index 5b912506c7..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Shape.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -val shapes = Shapes( - small = RoundedCornerShape(percent = 50), - medium = RoundedCornerShape(size = 0f), - large = RoundedCornerShape( - topStart = 16.dp, - topEnd = 0.dp, - bottomEnd = 0.dp, - bottomStart = 16.dp - ) -) diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Theme.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Theme.kt deleted file mode 100644 index 06d0b0a120..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Theme.kt +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Shapes -import androidx.compose.material.Typography -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.example.owl.R - -private val YellowThemeLight = lightColors( - primary = yellow500, - primaryVariant = yellow400, - onPrimary = Color.Black, - secondary = blue700, - secondaryVariant = blue800, - onSecondary = Color.White -) - -private val YellowThemeDark = darkColors( - primary = yellow200, - secondary = blue200, - onSecondary = Color.Black, - surface = yellowDarkPrimary -) - -@Composable -fun YellowTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colors = if (darkTheme) { - YellowThemeDark - } else { - YellowThemeLight - } - OwlTheme(darkTheme, colors, content) -} - -private val BlueThemeLight = lightColors( - primary = blue700, - onPrimary = Color.White, - primaryVariant = blue800, - secondary = yellow500 -) - -private val BlueThemeDark = darkColors( - primary = blue200, - secondary = yellow200, - surface = blueDarkPrimary -) - -@Composable -fun BlueTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colors = if (darkTheme) { - BlueThemeDark - } else { - BlueThemeLight - } - OwlTheme(darkTheme, colors, content) -} - -private val PinkThemeLight = lightColors( - primary = pink500, - secondary = pink500, - primaryVariant = pink600, - onPrimary = Color.Black, - onSecondary = Color.Black -) - -private val PinkThemeDark = darkColors( - primary = pink200, - secondary = pink200, - surface = pinkDarkPrimary -) - -@Composable -fun PinkTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colors = if (darkTheme) { - PinkThemeDark - } else { - PinkThemeLight - } - OwlTheme(darkTheme, colors, content) -} - -private val LightElevation = Elevations() - -private val DarkElevation = Elevations(card = 1.dp) - -private val LightImages = Images(lockupLogo = R.drawable.ic_lockup_blue) - -private val DarkImages = Images(lockupLogo = R.drawable.ic_lockup_white) - -@Composable -private fun OwlTheme( - darkTheme: Boolean, - colors: Colors, - content: @Composable () -> Unit -) { - val elevation = if (darkTheme) DarkElevation else LightElevation - val images = if (darkTheme) DarkImages else LightImages - CompositionLocalProvider( - LocalElevations provides elevation, - LocalImages provides images - ) { - MaterialTheme( - colors = colors, - typography = typography, - shapes = shapes, - content = content - ) - } -} - -/** - * Alternate to [MaterialTheme] allowing us to add our own theme systems (e.g. [Elevations]) or to - * extend [MaterialTheme]'s types e.g. return our own [Colors] extension - */ -object OwlTheme { - - /** - * Proxy to [MaterialTheme] - */ - val colors: Colors - @Composable - get() = MaterialTheme.colors - - /** - * Proxy to [MaterialTheme] - */ - val typography: Typography - @Composable - get() = MaterialTheme.typography - - /** - * Proxy to [MaterialTheme] - */ - val shapes: Shapes - @Composable - get() = MaterialTheme.shapes - - /** - * Retrieves the current [Elevations] at the call site's position in the hierarchy. - */ - val elevations: Elevations - @Composable - get() = LocalElevations.current - - /** - * Retrieves the current [Images] at the call site's position in the hierarchy. - */ - val images: Images - @Composable - get() = LocalImages.current -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Type.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Type.kt deleted file mode 100644 index b31e5e1c3c..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Type.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.material.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp -import com.example.owl.R - -private val fonts = FontFamily( - Font(R.font.rubik_regular), - Font(R.font.rubik_medium, FontWeight.W500), - Font(R.font.rubik_bold, FontWeight.Bold) -) - -val typography = typographyFromDefaults( - h1 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - h2 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - h3 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - h4 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold, - lineHeight = 40.sp - ), - h5 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - h6 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.W500, - lineHeight = 28.sp - ), - subtitle1 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.W500, - lineHeight = 22.sp - ), - subtitle2 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.W500 - ), - body1 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Normal, - lineHeight = 28.sp - ), - body2 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Normal, - lineHeight = 16.sp - ), - button = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - caption = TextStyle( - fontFamily = fonts - ), - overline = TextStyle( - letterSpacing = 0.08.em - ) -) - -fun typographyFromDefaults( - h1: TextStyle?, - h2: TextStyle?, - h3: TextStyle?, - h4: TextStyle?, - h5: TextStyle?, - h6: TextStyle?, - subtitle1: TextStyle?, - subtitle2: TextStyle?, - body1: TextStyle?, - body2: TextStyle?, - button: TextStyle?, - caption: TextStyle?, - overline: TextStyle? -): Typography { - val defaults = Typography() - return Typography( - h1 = defaults.h1.merge(h1), - h2 = defaults.h2.merge(h2), - h3 = defaults.h3.merge(h3), - h4 = defaults.h4.merge(h4), - h5 = defaults.h5.merge(h5), - h6 = defaults.h6.merge(h6), - subtitle1 = defaults.subtitle1.merge(subtitle1), - subtitle2 = defaults.subtitle2.merge(subtitle2), - body1 = defaults.body1.merge(body1), - body2 = defaults.body2.merge(body2), - button = defaults.button.merge(button), - caption = defaults.caption.merge(caption), - overline = defaults.overline.merge(overline) - ) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/utils/Lerp.kt b/Owl/app/src/main/java/com/example/owl/ui/utils/Lerp.kt deleted file mode 100644 index 0710e69b74..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/utils/Lerp.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.utils - -import androidx.annotation.FloatRange -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp as lerpColor - -/** - * Linearly interpolate between two values - */ -fun lerp( - startValue: Float, - endValue: Float, - @FloatRange(from = 0.0, to = 1.0) fraction: Float -): Float { - return startValue + fraction * (endValue - startValue) -} - -/** - * Linearly interpolate between two [Float]s when the [fraction] is in a given range. - */ -fun lerp( - startValue: Float, - endValue: Float, - @FloatRange(from = 0.0, to = 1.0) startFraction: Float, - @FloatRange(from = 0.0, to = 1.0) endFraction: Float, - @FloatRange(from = 0.0, to = 1.0) fraction: Float -): Float { - if (fraction < startFraction) return startValue - if (fraction > endFraction) return endValue - - return lerp(startValue, endValue, (fraction - startFraction) / (endFraction - startFraction)) -} - -/** - * Linearly interpolate between two [Color]s when the [fraction] is in a given range. - */ -fun lerp( - startColor: Color, - endColor: Color, - @FloatRange(from = 0.0, to = 1.0) startFraction: Float, - @FloatRange(from = 0.0, to = 1.0) endFraction: Float, - @FloatRange(from = 0.0, to = 1.0) fraction: Float -): Color { - if (fraction < startFraction) return startColor - if (fraction > endFraction) return endColor - - return lerpColor( - startColor, - endColor, - (fraction - startFraction) / (endFraction - startFraction) - ) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/utils/Navigation.kt b/Owl/app/src/main/java/com/example/owl/ui/utils/Navigation.kt deleted file mode 100644 index 3f88162da7..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/utils/Navigation.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.utils - -import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.staticCompositionLocalOf - -/** - * An effect for handling presses of the device back button. - */ -@Composable -fun backHandler( - enabled: Boolean = true, - onBack: () -> Unit -) { - // Safely update the current `onBack` lambda when a new one is provided - val currentOnBack by rememberUpdatedState(onBack) - // Remember in Composition a back callback that calls the `onBack` lambda - val backCallback = remember { - object : OnBackPressedCallback(enabled) { - override fun handleOnBackPressed() { - currentOnBack() - } - } - } - // On every successful composition, update the callback with the `enabled` value - SideEffect { - backCallback.isEnabled = enabled - } - val backDispatcher = LocalBackDispatcher.current - // If `backDispatcher` changes, dispose and reset the effect - DisposableEffect(backDispatcher) { - // Add callback to the backDispatcher - backDispatcher.addCallback(backCallback) - // When the effect leaves the Composition, remove the callback - onDispose { - backCallback.remove() - } - } -} - -/** - * An [androidx.compose.runtime.Ambient] providing the current [OnBackPressedDispatcher]. You must - * [provide][androidx.compose.runtime.Providers] a value before use. - */ -internal val LocalBackDispatcher = staticCompositionLocalOf { - error("No Back Dispatcher provided") -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/utils/NetworkImage.kt b/Owl/app/src/main/java/com/example/owl/ui/utils/NetworkImage.kt deleted file mode 100644 index 37630af83d..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/utils/NetworkImage.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.utils - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import coil.ImageLoader -import coil.annotation.ExperimentalCoilApi -import coil.intercept.Interceptor -import coil.request.ImageResult -import coil.size.PixelSize -import com.example.owl.ui.theme.compositedOnSurface -import com.google.accompanist.coil.CoilImage -import com.google.accompanist.coil.LocalImageLoader -import okhttp3.HttpUrl - -/** - * A wrapper around [CoilImage] setting a default [contentScale] and loading placeholder. - */ -@Composable -fun NetworkImage( - url: String, - contentDescription: String?, - modifier: Modifier = Modifier, - contentScale: ContentScale = ContentScale.Crop, - placeholderColor: Color? = MaterialTheme.colors.compositedOnSurface(0.2f) -) { - CoilImage( - data = url, - modifier = modifier, - contentDescription = contentDescription, - contentScale = contentScale, - loading = { - if (placeholderColor != null) { - Spacer( - modifier = Modifier - .fillMaxSize() - .background(placeholderColor) - ) - } - } - ) -} - -@Composable -fun ProvideImageLoader(content: @Composable () -> Unit) { - val context = LocalContext.current - val loader = remember(context) { - ImageLoader.Builder(context) - .componentRegistry { - add(UnsplashSizingInterceptor) - }.build() - } - CompositionLocalProvider(LocalImageLoader provides loader, content = content) -} - -/** - * A Coil [Interceptor] which appends query params to Unsplash urls to request sized images. - */ -@OptIn(ExperimentalCoilApi::class) -object UnsplashSizingInterceptor : Interceptor { - override suspend fun intercept(chain: Interceptor.Chain): ImageResult { - val data = chain.request.data - val size = chain.size - if (data is String && - data.startsWith("https://images.unsplash.com/photo-") && - size is PixelSize && - size.width > 0 && - size.height > 0 - ) { - val url = HttpUrl.parse(data)!! - .newBuilder() - .addQueryParameter("w", size.width.toString()) - .addQueryParameter("h", size.height.toString()) - .build() - val request = chain.request.newBuilder().data(url).build() - return chain.proceed(request) - } - return chain.proceed(chain.request) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/utils/Scrim.kt b/Owl/app/src/main/java/com/example/owl/ui/utils/Scrim.kt deleted file mode 100644 index 77bff3e14d..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/utils/Scrim.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.utils - -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color - -/** - * A [Modifier] which draws a vertical gradient - */ -fun Modifier.scrim(colors: List): Modifier = drawWithContent { - drawContent() - drawRect(Brush.verticalGradient(colors)) -} diff --git a/Owl/app/src/main/res/drawable-v26/ic_launcher_background.xml b/Owl/app/src/main/res/drawable-v26/ic_launcher_background.xml deleted file mode 100644 index eb01b4fe9f..0000000000 --- a/Owl/app/src/main/res/drawable-v26/ic_launcher_background.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - diff --git a/Owl/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Owl/app/src/main/res/drawable-v26/ic_launcher_foreground.xml deleted file mode 100644 index a61c0661ef..0000000000 --- a/Owl/app/src/main/res/drawable-v26/ic_launcher_foreground.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - diff --git a/Owl/app/src/main/res/drawable/ic_featured.xml b/Owl/app/src/main/res/drawable/ic_featured.xml deleted file mode 100644 index 5026e385eb..0000000000 --- a/Owl/app/src/main/res/drawable/ic_featured.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - diff --git a/Owl/app/src/main/res/drawable/ic_grain.xml b/Owl/app/src/main/res/drawable/ic_grain.xml deleted file mode 100644 index 03bbe544fa..0000000000 --- a/Owl/app/src/main/res/drawable/ic_grain.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/Owl/app/src/main/res/drawable/ic_lockup_blue.xml b/Owl/app/src/main/res/drawable/ic_lockup_blue.xml deleted file mode 100644 index f18db0a807..0000000000 --- a/Owl/app/src/main/res/drawable/ic_lockup_blue.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - diff --git a/Owl/app/src/main/res/drawable/ic_lockup_white.xml b/Owl/app/src/main/res/drawable/ic_lockup_white.xml deleted file mode 100644 index b467d362f5..0000000000 --- a/Owl/app/src/main/res/drawable/ic_lockup_white.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - diff --git a/Owl/app/src/main/res/drawable/ic_logo.xml b/Owl/app/src/main/res/drawable/ic_logo.xml deleted file mode 100644 index 62d9a45664..0000000000 --- a/Owl/app/src/main/res/drawable/ic_logo.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - diff --git a/Owl/app/src/main/res/drawable/ic_search.xml b/Owl/app/src/main/res/drawable/ic_search.xml deleted file mode 100644 index 926df864f6..0000000000 --- a/Owl/app/src/main/res/drawable/ic_search.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/Owl/app/src/main/res/font/rubik_bold.ttf b/Owl/app/src/main/res/font/rubik_bold.ttf deleted file mode 100755 index 4e77930f42..0000000000 Binary files a/Owl/app/src/main/res/font/rubik_bold.ttf and /dev/null differ diff --git a/Owl/app/src/main/res/font/rubik_medium.ttf b/Owl/app/src/main/res/font/rubik_medium.ttf deleted file mode 100755 index 9e358b2f40..0000000000 Binary files a/Owl/app/src/main/res/font/rubik_medium.ttf and /dev/null differ diff --git a/Owl/app/src/main/res/font/rubik_regular.ttf b/Owl/app/src/main/res/font/rubik_regular.ttf deleted file mode 100755 index 52b59ca4fd..0000000000 Binary files a/Owl/app/src/main/res/font/rubik_regular.ttf and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Owl/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 1a1449ec8a..0000000000 --- a/Owl/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/Owl/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100755 index 8abe367a25..0000000000 Binary files a/Owl/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100755 index 1f30502cb3..0000000000 Binary files a/Owl/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100755 index df8e3904a3..0000000000 Binary files a/Owl/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100755 index 13a8dd1bdd..0000000000 Binary files a/Owl/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100755 index 9434fca446..0000000000 Binary files a/Owl/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/values-night/themes.xml b/Owl/app/src/main/res/values-night/themes.xml deleted file mode 100644 index d444fdea51..0000000000 --- a/Owl/app/src/main/res/values-night/themes.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/Rally/build.gradle b/Rally/build.gradle deleted file mode 100644 index 35ef3be1d4..0000000000 --- a/Rally/build.gradle +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.compose.rally.buildsrc.Libs -import com.example.compose.rally.buildsrc.Urls -import com.example.compose.rally.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.10.0' -} - -subprojects { - repositories { - google() - mavenCentral() - jcenter() - - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { url Urls.composeSnapshotRepo } - maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - - ktlint(Versions.ktlint) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - - // Enable experimental coroutines APIs, including Flow - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' - freeCompilerArgs += '-Xopt-in=kotlin.Experimental' - - // Set JVM target to 1.8 - jvmTarget = "1.8" - } - } -} diff --git a/Rally/buildSrc/build.gradle.kts b/Rally/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Rally/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Rally/buildSrc/src/main/java/com/example/compose/rally/buildsrc/dependencies.kt b/Rally/buildSrc/src/main/java/com/example/compose/rally/buildsrc/dependencies.kt deleted file mode 100644 index d1a4a7457c..0000000000 --- a/Rally/buildSrc/src/main/java/com/example/compose/rally/buildsrc/dependencies.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.buildsrc - -object Versions { - const val ktlint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha11" - const val jdkDesugar = "com.android.tools:desugar_jdk_libs:1.0.9" - - const val material = "com.google.android.material:material:1.1.0" - - object Kotlin { - private const val version = "1.4.31" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.2" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object AndroidX { - const val appcompat = "androidx.appcompat:appcompat:1.2.0-rc01" - const val coreKtx = "androidx.core:core-ktx:1.5.0-beta03" - - object Activity { - const val activityCompose = "androidx.activity:activity-compose:1.3.0-alpha05" - } - - object Compose { - const val snapshot = "" - const val version = "1.0.0-beta03" - - const val core = "androidx.compose.ui:ui:$version" - const val foundation = "androidx.compose.foundation:foundation:$version" - const val layout = "androidx.compose.foundation:foundation-layout:$version" - const val material = "androidx.compose.material:material:$version" - const val materialIconsExtended = "androidx.compose.material:material-icons-extended:$version" - const val runtime = "androidx.compose.runtime:runtime:$version" - const val runtimeLivedata = "androidx.compose.runtime:runtime-livedata:$version" - const val tooling = "androidx.compose.ui:ui-tooling:$version" - const val test = "androidx.compose.ui:ui-test:$version" - const val uiTest = "androidx.compose.ui:ui-test-junit4:$version" - } - - object Lifecycle { - private const val version = "2.3.0" - const val extensions = "androidx.lifecycle:lifecycle-extensions:$version" - const val livedata = "androidx.lifecycle:lifecycle-livedata-ktx:$version" - const val viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - } - - object Navigation { - private const val version = "2.3.3" - const val fragment = "androidx.navigation:navigation-fragment-ktx:$version" - const val uiKtx = "androidx.navigation:navigation-ui-ktx:$version" - } - - object Test { - private const val version = "1.2.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - } -} - -object Urls { - const val composeSnapshotRepo = "https://androidx-dev-prod.appspot.com/snapshots/builds/" + - "${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" -} diff --git a/Rally/debug.keystore b/Rally/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Rally/debug.keystore and /dev/null differ diff --git a/Rally/gradle.properties b/Rally/gradle.properties deleted file mode 100644 index b2d834ce9c..0000000000 --- a/Rally/gradle.properties +++ /dev/null @@ -1,42 +0,0 @@ -# -# Copyright 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m - -# Turn on parallel compilation, caching and on-demand configuration -org.gradle.configureondemand=true -org.gradle.caching=true -org.gradle.parallel=true - -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true - -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Rally/gradle/wrapper/gradle-wrapper.jar b/Rally/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e708b1c023..0000000000 Binary files a/Rally/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/Rally/gradle/wrapper/gradle-wrapper.properties b/Rally/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2a563242c1..0000000000 --- a/Rally/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/Rally/gradlew b/Rally/gradlew deleted file mode 100755 index 4f906e0c81..0000000000 --- a/Rally/gradlew +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env sh - -# -# Copyright 2015 the original author or authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -exec "$JAVACMD" "$@" diff --git a/Rally/gradlew.bat b/Rally/gradlew.bat deleted file mode 100644 index ac1b06f938..0000000000 --- a/Rally/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/Rally/screenshots/donut.gif b/Rally/screenshots/donut.gif deleted file mode 100644 index 81705ede72..0000000000 Binary files a/Rally/screenshots/donut.gif and /dev/null differ diff --git a/Rally/screenshots/rally.gif b/Rally/screenshots/rally.gif deleted file mode 100644 index db55cad765..0000000000 Binary files a/Rally/screenshots/rally.gif and /dev/null differ diff --git a/Rally/settings.gradle b/Rally/settings.gradle deleted file mode 100644 index 94b0ac41e9..0000000000 --- a/Rally/settings.gradle +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -include ':app' -rootProject.name = "Rally" \ No newline at end of file diff --git a/Rally/spotless/copyright.kt b/Rally/spotless/copyright.kt deleted file mode 100644 index 806db0fb54..0000000000 --- a/Rally/spotless/copyright.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright $YEAR The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - diff --git a/Reply/.editorconfig b/Reply/.editorconfig new file mode 100644 index 0000000000..ae57e08c44 --- /dev/null +++ b/Reply/.editorconfig @@ -0,0 +1,26 @@ +# When authoring changes in .editorconfig, run ./gradlew spotlessApply --no-daemon +# Reference: https://github.com/diffplug/spotless/issues/1924 +[*.{kt,kts}] +ktlint_code_style = android_studio +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +max_line_length = 140 # ktlint official +ktlint_function_naming_ignore_when_annotated_with = Composable, Test +ktlint_standard_filename = disabled +ktlint_standard_package-name = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_backing-property-naming = disabled +ktlint_standard_argument-list-wrapping=disabled +ktlint_standard_parameter-list-wrapping = disabled +ktlint_standard_double-colon-spacing=disabled +ktlint_standard_enum-entry-name-case=disabled +ktlint_standard_multiline-if-else=disabled +ktlint_standard_no-empty-first-line-in-method-block = disabled +ktlint_standard_package-name = disabled +ktlint_standard_trailing-comma = disabled +ktlint_standard_spacing-around-angle-brackets = disabled +ktlint_standard_spacing-between-declarations-with-annotations = disabled +ktlint_standard_spacing-between-declarations-with-comments = disabled +ktlint_standard_unary-op-spacing = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_value-parameter-comment = disabled diff --git a/Reply/.gitignore b/Reply/.gitignore new file mode 100644 index 0000000000..834ecd9dff --- /dev/null +++ b/Reply/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +.kotlin/ diff --git a/Rally/ASSETS_LICENSE b/Reply/ASSETS_LICENSE similarity index 100% rename from Rally/ASSETS_LICENSE rename to Reply/ASSETS_LICENSE diff --git a/Reply/README.md b/Reply/README.md new file mode 100644 index 0000000000..b274582e80 --- /dev/null +++ b/Reply/README.md @@ -0,0 +1,106 @@ +# Reply sample + +This sample is a [Jetpack Compose][compose] implementation of [Reply][reply], a material design study for adaptive design. + +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). +[Resizeable Emulator](https://developer.android.com/about/versions/12/12L/get#resizable-emulator) +You can clone this repository or import the +project from Android Studio following the steps +[here](https://developer.android.com/jetpack/compose/setup#sample). + +This sample showcases: + +* Adaptive apps for mobile, tablets and foldables +* Material navigation components +* [Material 3 theming][materialtheming] & dynamic colors. + +## Design & Screenshots + + + + + +## Features + +#### [Dynamic window resizing](app/src/main/java/com/example/reply/ui/ReplyApp.kt#74) +The [WindowSizeClass](https://developer.android.com/reference/kotlin/androidx/compose/material3/windowsizeclass/WindowSizeClass) allows us to get to know about current device size and configuration +and observe any changes in device size in case of orientation change or unfolding of device. + + + + +#### [Dynamic fold detection](app/src/main/java/com/example/reply/ui/MainActivity.kt#56) +The [WindowLayoutInfo](https://developer.android.com/reference/kotlin/androidx/window/layout/WindowLayoutInfo) let us observe all display features including [Folding Postures](app/src/main/java/com/example/reply/ui/utils/WindowStateUtils.kt) +real-time whenever fold state changes to help us adjust our UI accordingly. + + + + +#### [Material 3 navigation components](app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationComponents.kt) +The sample provides usage of material navigation components depending on screen size and states. These components also are part of material guidelines for canonical layouts to improve user experience and ergonomics. +* [`BottomNavigationBar`](app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationComponents.kt#162) is used for compact devices with maximum of 5 navigation destinations. +* [`NavigationRail`](app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationComponents.kt#70) is used for medium size devices. It is also used along with [`ModalNavigationDrawer`](app/src/main/java/com/example/reply/ui/ReplyApp.kt#73) when user want to see more content. +* [`PermanentNavigationDrawer`](app/src/main/java/com/example/reply/ui/ReplyApp.kt#153) is used for large devices or desktops when we have enough space to show navigation drawer content always. +* Depending upon the different size and state of device correct [navigation type](app/src/main/java/com/example/reply/ui/ReplyApp.kt#71) is chosen dynamically. + + + + + + + +#### [Material 3 Theming](app/src/main/java/com/example/reply/ui/theme) +Reply is using brand new Material 3 [colors](app/src/main/java/com/example/reply/ui/theme/Color.kt), [typography](app/src/main/java/com/example/reoly/ui/theme/Type.kt) and [theming](app/src/main/java/com/example/reply/ui/theme/Theme.kt). It also supports both [light and dark mode]((app/src/main/java/com/example/reply/ui/theme/Theme.kt#95)) depending on system settings. +[Material Theme builder](https://material-foundation.github.io/material-theme-builder/#/custom) is used to create material 3 theme and directly export it for Compose. + +#### [Dynamic theming/Material You](app/src/main/java/com/example/reply/ui/theme/Theme.kt#100) +On Android 12+ Reply supports Material You dynamic color, which extracts a custom color scheme from the device wallpaper. For older version of android it falls back to defined light and dark [color schemes](app/src/main/java/com/example/reply/ui/theme/Theme.kt#L34) + + + + + + + +#### [Inbox Screen](app/src/main/java/com/example/reply/ui/ReplyListContent.kt) +Similar to navigation type, depending on device's size and state correct [content type](app/src/main/java/com/example/reply/ui/ReplyApp.kt#72) is chosen, we can have [Inbox only](app/src/main/java/com/example/reply/ui/ReplyListContent.kt#91) or [Inbox and thread detail](app/src/main/java/com/example/reply/ui/ReplyListContent.kt#83) together. The content in inbox screen +is adaptive and is switched between list only or list and detail page depending on the screen size available. + + + + + + + +#### [FAB & Material 3 components](app/src/main/java/com/example/reply/ui/ReplyListContent.kt) +Reply is using all material 3 components including different type of FAB for different screen size and states. +* [`LargeFloatingActionButton`](app/src/main/java/com/example/reply/ui/ReplyListContent.kt#100) is used along with bottom navigation ber. +* [`FloatingActionButton`](app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationComponents.kt#87) is used with Navigation rail for medium to large tablets. +* [`ExtendedFloatingActionButton`](app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationComponents.kt#214) is used in Navigation drawer for large devices. + +#### [Data](app/src/main/java/com/example/reply/data) +Reply has static local data providers for [email](app/src/main/java/com/example/reply/data/local/LocalEmailsDataProvider.kt) and [account](app/src/main/java/com/example/reply/data/local/LocalAccountsDataProvider.kt) data. It is also using repository pattern where [EmailRepository](app/src/main/java/com/example/reply/data/EmailsRepository.kt) +emits the flow of email from local data that is used in [ReplyHomeViewModel](app/src/main/java/com/example/reply/ui/ReplyHomeViewModel.kt) to observe +it in view model scope. The `ViewModel` exposes this data to ReplyApp composable via [state flow](app/src/main/java/com/example/reply/ui/ReplyHomeViewModel.kt#34). + +## License +``` +Copyright 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +[compose]: https://developer.android.com/jetpack/compose +[reply]: https://m3.material.io/foundations/adaptive-design/overview +[materialtheming]: https://m3.material.io/styles/color/dynamic-color/overview diff --git a/Reply/app/.gitignore b/Reply/app/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Reply/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Reply/app/build.gradle.kts b/Reply/app/build.gradle.kts new file mode 100644 index 0000000000..5038a41d71 --- /dev/null +++ b/Reply/app/build.gradle.kts @@ -0,0 +1,140 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.compose) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "com.example.reply" + + defaultConfig { + applicationId = "com.example.reply" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + vectorDrawables.useSupportLibrary = true + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + // Important: change the keystore for a production deployment + val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore") + val localKeystore = rootProject.file("debug_2.keystore") + val hasKeyInfo = userKeystore.exists() + create("release") { + // get from env variables + storeFile = if (hasKeyInfo) userKeystore else localKeystore + storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password") + keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias") + keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password") + } + } + + buildTypes { + getByName("debug") { + } + + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + // Tests can be Robolectric or instrumented tests + sourceSets { + val sharedTestDir = "src/sharedTest/java" + getByName("test") { + java.srcDir(sharedTestDir) + } + getByName("androidTest") { + java.srcDir(sharedTestDir) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("17") + } + } + + buildFeatures { + compose = true + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.androidx.core.ktx) + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive.navigationSuite) + implementation("com.google.accompanist:accompanist-adaptive:0.26.2-beta") + + implementation(libs.androidx.compose.materialWindow) + + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.window) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.compose.ui.test) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/Reply/app/proguard-rules.pro b/Reply/app/proguard-rules.pro new file mode 100644 index 0000000000..058075b933 --- /dev/null +++ b/Reply/app/proguard-rules.pro @@ -0,0 +1,46 @@ +# Copyright 2022 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE diff --git a/Reply/app/src/main/AndroidManifest.xml b/Reply/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c6273685ce --- /dev/null +++ b/Reply/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + diff --git a/Reply/app/src/main/ic_launcher-playstore.png b/Reply/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..67cf15fa42 Binary files /dev/null and b/Reply/app/src/main/ic_launcher-playstore.png differ diff --git a/Reply/app/src/main/java/com/example/reply/data/Account.kt b/Reply/app/src/main/java/com/example/reply/data/Account.kt new file mode 100644 index 0000000000..60b1cb9aab --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/Account.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data + +import androidx.annotation.DrawableRes + +/** + * An object which represents an account which can belong to a user. A single user can have + * multiple accounts. + */ +data class Account( + val id: Long, + val uid: Long, + val firstName: String, + val lastName: String, + val email: String, + val altEmail: String, + @DrawableRes val avatar: Int, + var isCurrentAccount: Boolean = false, +) { + val fullName: String = "$firstName $lastName" +} diff --git a/Reply/app/src/main/java/com/example/reply/data/AccountsRepository.kt b/Reply/app/src/main/java/com/example/reply/data/AccountsRepository.kt new file mode 100644 index 0000000000..6cd255f4a2 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/AccountsRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data + +import kotlinx.coroutines.flow.Flow + +/** + * An Interface contract to get all accounts info for User. + */ +interface AccountsRepository { + fun getDefaultUserAccount(): Flow + fun getAllUserAccounts(): Flow> + fun getContactAccountByUid(uid: Long): Flow +} diff --git a/Reply/app/src/main/java/com/example/reply/data/AccountsRepositoryImpl.kt b/Reply/app/src/main/java/com/example/reply/data/AccountsRepositoryImpl.kt new file mode 100644 index 0000000000..577f6f765d --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/AccountsRepositoryImpl.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data + +import com.example.reply.data.local.LocalAccountsDataProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class AccountsRepositoryImpl : AccountsRepository { + + override fun getDefaultUserAccount(): Flow = flow { + emit(LocalAccountsDataProvider.getDefaultUserAccount()) + } + + override fun getAllUserAccounts(): Flow> = flow { + emit(LocalAccountsDataProvider.allUserAccounts) + } + + override fun getContactAccountByUid(uid: Long): Flow = flow { + emit(LocalAccountsDataProvider.getContactAccountByUid(uid)) + } +} diff --git a/Reply/app/src/main/java/com/example/reply/data/Email.kt b/Reply/app/src/main/java/com/example/reply/data/Email.kt new file mode 100644 index 0000000000..8a1ec24276 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/Email.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data + +/** + * A simple data class to represent an Email. + */ +data class Email( + val id: Long, + val sender: Account, + val recipients: List = emptyList(), + val subject: String, + val body: String, + val attachments: List = emptyList(), + var isImportant: Boolean = false, + var isStarred: Boolean = false, + var mailbox: MailboxType = MailboxType.INBOX, + val createdAt: String, + val threads: List = emptyList(), +) diff --git a/Reply/app/src/main/java/com/example/reply/data/EmailAttachment.kt b/Reply/app/src/main/java/com/example/reply/data/EmailAttachment.kt new file mode 100644 index 0000000000..b279ce781c --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/EmailAttachment.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data + +import androidx.annotation.DrawableRes + +/** + * An object class to define an attachment to email object. + */ +data class EmailAttachment(@DrawableRes val resId: Int, val contentDesc: String) diff --git a/Reply/app/src/main/java/com/example/reply/data/EmailsRepository.kt b/Reply/app/src/main/java/com/example/reply/data/EmailsRepository.kt new file mode 100644 index 0000000000..9b2684a33e --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/EmailsRepository.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data + +import kotlinx.coroutines.flow.Flow + +/** + * An Interface contract to get all emails info for a User. + */ +interface EmailsRepository { + fun getAllEmails(): Flow> + fun getCategoryEmails(category: MailboxType): Flow> + fun getAllFolders(): List + fun getEmailFromId(id: Long): Flow +} diff --git a/Reply/app/src/main/java/com/example/reply/data/EmailsRepositoryImpl.kt b/Reply/app/src/main/java/com/example/reply/data/EmailsRepositoryImpl.kt new file mode 100644 index 0000000000..58b118ff4e --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/EmailsRepositoryImpl.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data + +import com.example.reply.data.local.LocalEmailsDataProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class EmailsRepositoryImpl : EmailsRepository { + + override fun getAllEmails(): Flow> = flow { + emit(LocalEmailsDataProvider.allEmails) + } + + override fun getCategoryEmails(category: MailboxType): Flow> = flow { + val categoryEmails = LocalEmailsDataProvider.allEmails.filter { it.mailbox == category } + emit(categoryEmails) + } + + override fun getAllFolders(): List { + return LocalEmailsDataProvider.getAllFolders() + } + + override fun getEmailFromId(id: Long): Flow = flow { + val categoryEmails = LocalEmailsDataProvider.allEmails.firstOrNull { it.id == id } + } +} diff --git a/Reply/app/src/main/java/com/example/reply/data/MailboxType.kt b/Reply/app/src/main/java/com/example/reply/data/MailboxType.kt new file mode 100644 index 0000000000..9c711b9859 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/MailboxType.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data + +/** + * An enum class to define different types of email folders or categories. + */ +enum class MailboxType { + INBOX, + DRAFTS, + SENT, + SPAM, + TRASH, +} diff --git a/Reply/app/src/main/java/com/example/reply/data/local/LocalAccountsDataProvider.kt b/Reply/app/src/main/java/com/example/reply/data/local/LocalAccountsDataProvider.kt new file mode 100644 index 0000000000..65edc79aff --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/local/LocalAccountsDataProvider.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data.local + +import com.example.reply.R +import com.example.reply.data.Account + +/** + * An static data store of [Account]s. This includes both [Account]s owned by the current user and + * all [Account]s of the current user's contacts. + */ +object LocalAccountsDataProvider { + + val allUserAccounts = listOf( + Account( + id = 1L, + uid = 0L, + firstName = "Jeff", + lastName = "Hansen", + email = "hikingfan@gmail.com", + altEmail = "hkngfan@outside.com", + avatar = R.drawable.avatar_10, + isCurrentAccount = true, + ), + Account( + id = 2L, + uid = 0L, + firstName = "Jeff", + lastName = "H", + email = "jeffersonloveshiking@gmail.com", + altEmail = "jeffersonloveshiking@work.com", + avatar = R.drawable.avatar_2, + ), + Account( + id = 3L, + uid = 0L, + firstName = "Jeff", + lastName = "Hansen", + email = "jeffersonc@google.com", + altEmail = "jeffersonc@gmail.com", + avatar = R.drawable.avatar_9, + ), + ) + + private val allUserContactAccounts = listOf( + Account( + id = 4L, + uid = 1L, + firstName = "Tracy", + lastName = "Alvarez", + email = "tracealvie@gmail.com", + altEmail = "tracealvie@gravity.com", + avatar = R.drawable.avatar_1, + ), + Account( + id = 5L, + uid = 2L, + firstName = "Allison", + lastName = "Trabucco", + email = "atrabucco222@gmail.com", + altEmail = "atrabucco222@work.com", + avatar = R.drawable.avatar_3, + ), + Account( + id = 6L, + uid = 3L, + firstName = "Ali", + lastName = "Connors", + email = "aliconnors@gmail.com", + altEmail = "aliconnors@android.com", + avatar = R.drawable.avatar_5, + ), + Account( + id = 7L, + uid = 4L, + firstName = "Alberto", + lastName = "Williams", + email = "albertowilliams124@gmail.com", + altEmail = "albertowilliams124@chromeos.com", + avatar = R.drawable.avatar_0, + ), + Account( + id = 8L, + uid = 5L, + firstName = "Kim", + lastName = "Alen", + email = "alen13@gmail.com", + altEmail = "alen13@mountainview.gov", + avatar = R.drawable.avatar_7, + ), + Account( + id = 9L, + uid = 6L, + firstName = "Google", + lastName = "Express", + email = "express@google.com", + altEmail = "express@gmail.com", + avatar = R.drawable.avatar_express, + ), + Account( + id = 10L, + uid = 7L, + firstName = "Sandra", + lastName = "Adams", + email = "sandraadams@gmail.com", + altEmail = "sandraadams@textera.com", + avatar = R.drawable.avatar_2, + ), + Account( + id = 11L, + uid = 8L, + firstName = "Trevor", + lastName = "Hansen", + email = "trevorhandsen@gmail.com", + altEmail = "trevorhandsen@express.com", + avatar = R.drawable.avatar_8, + ), + Account( + id = 12L, + uid = 9L, + firstName = "Sean", + lastName = "Holt", + email = "sholt@gmail.com", + altEmail = "sholt@art.com", + avatar = R.drawable.avatar_6, + ), + Account( + id = 13L, + uid = 10L, + firstName = "Frank", + lastName = "Hawkins", + email = "fhawkank@gmail.com", + altEmail = "fhawkank@thisisme.com", + avatar = R.drawable.avatar_4, + ), + ) + + /** + * Get the current user's default account. + */ + fun getDefaultUserAccount() = allUserAccounts.first() + + /** + * Whether or not the given [Account.id] uid is an account owned by the current user. + */ + fun isUserAccount(uid: Long): Boolean = allUserAccounts.any { it.uid == uid } + + /** + * Get the contact of the current user with the given [accountId]. + */ + fun getContactAccountByUid(accountId: Long): Account { + return allUserContactAccounts.first { it.id == accountId } + } +} diff --git a/Reply/app/src/main/java/com/example/reply/data/local/LocalEmailsDataProvider.kt b/Reply/app/src/main/java/com/example/reply/data/local/LocalEmailsDataProvider.kt new file mode 100644 index 0000000000..4c0c3a84d1 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/local/LocalEmailsDataProvider.kt @@ -0,0 +1,336 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.data.local + +import com.example.reply.R +import com.example.reply.data.Email +import com.example.reply.data.EmailAttachment +import com.example.reply.data.MailboxType + +/** + * A static data store of [Email]s. + */ + +object LocalEmailsDataProvider { + + private val threads = listOf( + Email( + id = 8L, + sender = LocalAccountsDataProvider.getContactAccountByUid(13L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Your update on Google Play Store is live!", + body = """ + Your update, 0.1.1, is now live on the Play Store and available for your alpha users to start testing. + + Your alpha testers will be automatically notified. If you'd rather send them a link directly, go to your Google Play Console and follow the instructions for obtaining an open alpha testing link. + """.trimIndent(), + mailbox = MailboxType.TRASH, + createdAt = "3 hours ago", + ), + Email( + id = 5L, + sender = LocalAccountsDataProvider.getContactAccountByUid(13L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Update to Your Itinerary", + body = "", + createdAt = "2 hours ago", + ), + Email( + id = 6L, + sender = LocalAccountsDataProvider.getContactAccountByUid(10L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Recipe to try", + "Raspberry Pie: We should make this pie recipe tonight! The filling is " + + "very quick to put together.", + createdAt = "2 hours ago", + mailbox = MailboxType.SENT, + ), + Email( + id = 7L, + sender = LocalAccountsDataProvider.getContactAccountByUid(9L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Delivered", + body = "Your shoes should be waiting for you at home!", + createdAt = "2 hours ago", + ), + Email( + id = 9L, + sender = LocalAccountsDataProvider.getContactAccountByUid(10L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "(No subject)", + body = """ + Hey, + + Wanted to email and see what you thought of + """.trimIndent(), + createdAt = "3 hours ago", + mailbox = MailboxType.DRAFTS, + ), + Email( + id = 1L, + sender = LocalAccountsDataProvider.getContactAccountByUid(6L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Brunch this weekend?", + body = """ + I'll be in your neighborhood doing errands and was hoping to catch you for a coffee this Saturday. If you don't have anything scheduled, it would be great to see you! It feels like its been forever. + + If we do get a chance to get together, remind me to tell you about Kim. She stopped over at the house to say hey to the kids and told me all about her trip to Mexico. + + Talk to you soon, + + Ali + """.trimIndent(), + createdAt = "40 mins ago", + ), + Email( + id = 2L, + sender = LocalAccountsDataProvider.getContactAccountByUid(5L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Bonjour from Paris", + body = "Here are some great shots from my trip...", + attachments = listOf( + EmailAttachment(R.drawable.paris_1, "Bridge in Paris"), + EmailAttachment(R.drawable.paris_2, "Bridge in Paris at night"), + EmailAttachment(R.drawable.paris_3, "City street in Paris"), + EmailAttachment(R.drawable.paris_4, "Street with bike in Paris"), + ), + isImportant = true, + createdAt = "1 hour ago", + ), + ) + + val allEmails = listOf( + Email( + id = 0L, + sender = LocalAccountsDataProvider.getContactAccountByUid(9L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Package shipped!", + body = """ + Cucumber Mask Facial has shipped. + + Keep an eye out for a package to arrive between this Thursday and next Tuesday. If for any reason you don't receive your package before the end of next week, please reach out to us for details on your shipment. + + As always, thank you for shopping with us and we hope you love our specially formulated Cucumber Mask! + """.trimIndent(), + createdAt = "20 mins ago", + isStarred = true, + threads = threads, + ), + Email( + id = 1L, + sender = LocalAccountsDataProvider.getContactAccountByUid(6L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Brunch this weekend?", + body = """ + I'll be in your neighborhood doing errands and was hoping to catch you for a coffee this Saturday. If you don't have anything scheduled, it would be great to see you! It feels like its been forever. + + If we do get a chance to get together, remind me to tell you about Kim. She stopped over at the house to say hey to the kids and told me all about her trip to Mexico. + + Talk to you soon, + + Ali + """.trimIndent(), + createdAt = "40 mins ago", + threads = threads.shuffled(), + ), + Email( + 2L, + LocalAccountsDataProvider.getContactAccountByUid(5L), + listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + "Bonjour from Paris", + "Here are some great shots from my trip...", + listOf( + EmailAttachment(R.drawable.paris_1, "Bridge in Paris"), + EmailAttachment(R.drawable.paris_2, "Bridge in Paris at night"), + EmailAttachment(R.drawable.paris_3, "City street in Paris"), + EmailAttachment(R.drawable.paris_4, "Street with bike in Paris"), + ), + true, + createdAt = "1 hour ago", + threads = threads.shuffled(), + ), + Email( + 3L, + LocalAccountsDataProvider.getContactAccountByUid(8L), + listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + "High school reunion?", + """ + Hi friends, + + I was at the grocery store on Sunday night.. when I ran into Genie Williams! I almost didn't recognize her afer 20 years! + + Anyway, it turns out she is on the organizing committee for the high school reunion this fall. I don't know if you were planning on going or not, but she could definitely use our help in trying to track down lots of missing alums. If you can make it, we're doing a little phone-tree party at her place next Saturday, hoping that if we can find one person, thee more will... + """.trimIndent(), + createdAt = "2 hours ago", + mailbox = MailboxType.SENT, + threads = threads.shuffled(), + ), + Email( + id = 4L, + sender = LocalAccountsDataProvider.getContactAccountByUid(11L), + recipients = listOf( + LocalAccountsDataProvider.getDefaultUserAccount(), + LocalAccountsDataProvider.getContactAccountByUid(8L), + LocalAccountsDataProvider.getContactAccountByUid(5L), + ), + subject = "Brazil trip", + body = """ + Thought we might be able to go over some details about our upcoming vacation. + + I've been doing a bit of research and have come across a few paces in Northern Brazil that I think we should check out. One, the north has some of the most predictable wind on the planet. I'd love to get out on the ocean and kitesurf for a couple of days if we're going to be anywhere near or around Taiba. I hear it's beautiful there and if you're up for it, I'd love to go. Other than that, I haven't spent too much time looking into places along our road trip route. I'm assuming we can find places to stay and things to do as we drive and find places we think look interesting. But... I know you're more of a planner, so if you have ideas or places in mind, lets jot some ideas down! + + Maybe we can jump on the phone later today if you have a second. + """.trimIndent(), + createdAt = "2 hours ago", + isStarred = true, + threads = threads.shuffled(), + ), + Email( + id = 5L, + sender = LocalAccountsDataProvider.getContactAccountByUid(13L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Update to Your Itinerary", + body = "", + createdAt = "2 hours ago", + threads = threads.shuffled(), + ), + Email( + id = 6L, + sender = LocalAccountsDataProvider.getContactAccountByUid(10L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Recipe to try", + "Raspberry Pie: We should make this pie recipe tonight! The filling is " + + "very quick to put together.", + createdAt = "2 hours ago", + mailbox = MailboxType.SENT, + threads = threads.shuffled(), + ), + Email( + id = 7L, + sender = LocalAccountsDataProvider.getContactAccountByUid(9L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Delivered", + body = "Your shoes should be waiting for you at home!", + createdAt = "2 hours ago", + threads = threads.shuffled(), + ), + Email( + id = 8L, + sender = LocalAccountsDataProvider.getContactAccountByUid(13L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Your update on Google Play Store is live!", + body = """ + Your update, 0.1.1, is now live on the Play Store and available for your alpha users to start testing. + + Your alpha testers will be automatically notified. If you'd rather send them a link directly, go to your Google Play Console and follow the instructions for obtaining an open alpha testing link. + """.trimIndent(), + mailbox = MailboxType.TRASH, + createdAt = "3 hours ago", + threads = threads.shuffled(), + ), + Email( + id = 9L, + sender = LocalAccountsDataProvider.getContactAccountByUid(10L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "(No subject)", + body = """ + Hey, + + Wanted to email and see what you thought of + """.trimIndent(), + createdAt = "3 hours ago", + mailbox = MailboxType.DRAFTS, + threads = threads.shuffled(), + ), + Email( + id = 10L, + sender = LocalAccountsDataProvider.getContactAccountByUid(5L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Try a free TrailGo account", + body = """ + Looking for the best hiking trails in your area? TrailGo gets you on the path to the outdoors faster than you can pack a sandwich. + + Whether you're an experienced hiker or just looking to get outside for the afternoon, there's a segment that suits you. + """.trimIndent(), + createdAt = "3 hours ago", + mailbox = MailboxType.TRASH, + threads = threads.shuffled(), + ), + Email( + id = 11L, + sender = LocalAccountsDataProvider.getContactAccountByUid(5L), + recipients = listOf(LocalAccountsDataProvider.getDefaultUserAccount()), + subject = "Free money", + body = """ + You've been selected as a winner in our latest raffle! To claim your prize, click on the link. + """.trimIndent(), + createdAt = "3 hours ago", + mailbox = MailboxType.SPAM, + threads = threads.shuffled(), + ), + ) + + /** + * Get an [Email] with the given [id]. + */ + fun get(id: Long): Email? { + return allEmails.firstOrNull { it.id == id } + } + + /** + * Create a new, blank [Email]. + */ + fun create(): Email { + return Email( + System.nanoTime(), // Unique ID generation. + LocalAccountsDataProvider.getDefaultUserAccount(), + createdAt = "Just now", + subject = "Monthly hosting party", + body = "I would like to invite everyone to our monthly event hosting party", + ) + } + + /** + * Create a new [Email] that is a reply to the email with the given [replyToId]. + */ + fun createReplyTo(replyToId: Long): Email { + val replyTo = get(replyToId) ?: return create() + return Email( + id = System.nanoTime(), + sender = replyTo.recipients.firstOrNull() + ?: LocalAccountsDataProvider.getDefaultUserAccount(), + recipients = listOf(replyTo.sender) + replyTo.recipients, + subject = replyTo.subject, + isStarred = replyTo.isStarred, + isImportant = replyTo.isImportant, + createdAt = "Just now", + body = "Responding to the above conversation.", + ) + } + + /** + * Get a list of [EmailFolder]s by which [Email]s can be categorized. + */ + fun getAllFolders() = listOf( + "Receipts", + "Pine Elementary", + "Taxes", + "Vacation", + "Mortgage", + "Grocery coupons", + ) +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/EmptyComingSoon.kt b/Reply/app/src/main/java/com/example/reply/ui/EmptyComingSoon.kt new file mode 100644 index 0000000000..45739d1580 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/EmptyComingSoon.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.reply.R + +@Composable +fun EmptyComingSoon(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(8.dp), + text = stringResource(id = R.string.empty_screen_title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary, + ) + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = stringResource(id = R.string.empty_screen_subtitle), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.outline, + ) + } +} + +@Preview +@Composable +fun ComingSoonPreview() { + EmptyComingSoon() +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/MainActivity.kt b/Reply/app/src/main/java/com/example/reply/ui/MainActivity.kt new file mode 100644 index 0000000000..7aaf705e8b --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/MainActivity.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.reply.data.local.LocalEmailsDataProvider +import com.example.reply.ui.theme.ContrastAwareReplyTheme +import com.google.accompanist.adaptive.calculateDisplayFeatures + +class MainActivity : ComponentActivity() { + + private val viewModel: ReplyHomeViewModel by viewModels() + + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setContent { + ContrastAwareReplyTheme { + val windowSize = calculateWindowSizeClass(this) + val displayFeatures = calculateDisplayFeatures(this) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ReplyApp( + windowSize = windowSize, + displayFeatures = displayFeatures, + replyHomeUIState = uiState, + closeDetailScreen = { + viewModel.closeDetailScreen() + }, + navigateToDetail = { emailId, pane -> + viewModel.setOpenedEmail(emailId, pane) + }, + toggleSelectedEmail = { emailId -> + viewModel.toggleSelectedEmail(emailId) + }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(showBackground = true) +@Composable +fun ReplyAppPreview() { + ContrastAwareReplyTheme { + ReplyApp( + replyHomeUIState = ReplyHomeUIState(emails = LocalEmailsDataProvider.allEmails), + windowSize = WindowSizeClass.calculateFromSize(DpSize(400.dp, 900.dp)), + displayFeatures = emptyList(), + ) + } +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(showBackground = true, widthDp = 700, heightDp = 500) +@Composable +fun ReplyAppPreviewTablet() { + ContrastAwareReplyTheme { + ReplyApp( + replyHomeUIState = ReplyHomeUIState(emails = LocalEmailsDataProvider.allEmails), + windowSize = WindowSizeClass.calculateFromSize(DpSize(700.dp, 500.dp)), + displayFeatures = emptyList(), + ) + } +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(showBackground = true, widthDp = 500, heightDp = 700) +@Composable +fun ReplyAppPreviewTabletPortrait() { + ContrastAwareReplyTheme { + ReplyApp( + replyHomeUIState = ReplyHomeUIState(emails = LocalEmailsDataProvider.allEmails), + windowSize = WindowSizeClass.calculateFromSize(DpSize(500.dp, 700.dp)), + displayFeatures = emptyList(), + ) + } +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(showBackground = true, widthDp = 1100, heightDp = 600) +@Composable +fun ReplyAppPreviewDesktop() { + ContrastAwareReplyTheme { + ReplyApp( + replyHomeUIState = ReplyHomeUIState(emails = LocalEmailsDataProvider.allEmails), + windowSize = WindowSizeClass.calculateFromSize(DpSize(1100.dp, 600.dp)), + displayFeatures = emptyList(), + ) + } +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Preview(showBackground = true, widthDp = 600, heightDp = 1100) +@Composable +fun ReplyAppPreviewDesktopPortrait() { + ContrastAwareReplyTheme { + ReplyApp( + replyHomeUIState = ReplyHomeUIState(emails = LocalEmailsDataProvider.allEmails), + windowSize = WindowSizeClass.calculateFromSize(DpSize(600.dp, 1100.dp)), + displayFeatures = emptyList(), + ) + } +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/ReplyApp.kt b/Reply/app/src/main/java/com/example/reply/ui/ReplyApp.kt new file mode 100644 index 0000000000..921f8e3142 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/ReplyApp.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui + +import androidx.compose.material3.Surface +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.window.layout.DisplayFeature +import androidx.window.layout.FoldingFeature +import com.example.reply.ui.navigation.ReplyNavigationActions +import com.example.reply.ui.navigation.ReplyNavigationWrapper +import com.example.reply.ui.navigation.Route +import com.example.reply.ui.utils.DevicePosture +import com.example.reply.ui.utils.ReplyContentType +import com.example.reply.ui.utils.ReplyNavigationType +import com.example.reply.ui.utils.isBookPosture +import com.example.reply.ui.utils.isSeparating + +private fun NavigationSuiteType.toReplyNavType() = when (this) { + NavigationSuiteType.NavigationBar -> ReplyNavigationType.BOTTOM_NAVIGATION + NavigationSuiteType.NavigationRail -> ReplyNavigationType.NAVIGATION_RAIL + NavigationSuiteType.NavigationDrawer -> ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER + else -> ReplyNavigationType.BOTTOM_NAVIGATION +} + +@Composable +fun ReplyApp( + windowSize: WindowSizeClass, + displayFeatures: List, + replyHomeUIState: ReplyHomeUIState, + closeDetailScreen: () -> Unit = {}, + navigateToDetail: (Long, ReplyContentType) -> Unit = { _, _ -> }, + toggleSelectedEmail: (Long) -> Unit = { }, +) { + /** + * We are using display's folding features to map the device postures a fold is in. + * In the state of folding device If it's half fold in BookPosture we want to avoid content + * at the crease/hinge + */ + val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() + + val foldingDevicePosture = when { + isBookPosture(foldingFeature) -> + DevicePosture.BookPosture(foldingFeature.bounds) + + isSeparating(foldingFeature) -> + DevicePosture.Separating(foldingFeature.bounds, foldingFeature.orientation) + + else -> DevicePosture.NormalPosture + } + + val contentType = when (windowSize.widthSizeClass) { + WindowWidthSizeClass.Compact -> ReplyContentType.SINGLE_PANE + + WindowWidthSizeClass.Medium -> if (foldingDevicePosture != DevicePosture.NormalPosture) { + ReplyContentType.DUAL_PANE + } else { + ReplyContentType.SINGLE_PANE + } + + WindowWidthSizeClass.Expanded -> ReplyContentType.DUAL_PANE + + else -> ReplyContentType.SINGLE_PANE + } + + val navController = rememberNavController() + val navigationActions = remember(navController) { + ReplyNavigationActions(navController) + } + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + Surface { + ReplyNavigationWrapper( + currentDestination = currentDestination, + navigateToTopLevelDestination = navigationActions::navigateTo, + ) { + ReplyNavHost( + navController = navController, + contentType = contentType, + displayFeatures = displayFeatures, + replyHomeUIState = replyHomeUIState, + navigationType = navSuiteType.toReplyNavType(), + closeDetailScreen = closeDetailScreen, + navigateToDetail = navigateToDetail, + toggleSelectedEmail = toggleSelectedEmail, + ) + } + } +} + +@Composable +private fun ReplyNavHost( + navController: NavHostController, + contentType: ReplyContentType, + displayFeatures: List, + replyHomeUIState: ReplyHomeUIState, + navigationType: ReplyNavigationType, + closeDetailScreen: () -> Unit, + navigateToDetail: (Long, ReplyContentType) -> Unit, + toggleSelectedEmail: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + NavHost( + modifier = modifier, + navController = navController, + startDestination = Route.Inbox, + ) { + composable { + ReplyInboxScreen( + contentType = contentType, + replyHomeUIState = replyHomeUIState, + navigationType = navigationType, + displayFeatures = displayFeatures, + closeDetailScreen = closeDetailScreen, + navigateToDetail = navigateToDetail, + toggleSelectedEmail = toggleSelectedEmail, + ) + } + composable { + EmptyComingSoon() + } + composable { + EmptyComingSoon() + } + composable { + EmptyComingSoon() + } + } +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/ReplyHomeViewModel.kt b/Reply/app/src/main/java/com/example/reply/ui/ReplyHomeViewModel.kt new file mode 100644 index 0000000000..4e84dd04b4 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/ReplyHomeViewModel.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.reply.data.Email +import com.example.reply.data.EmailsRepository +import com.example.reply.data.EmailsRepositoryImpl +import com.example.reply.ui.utils.ReplyContentType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch + +class ReplyHomeViewModel(private val emailsRepository: EmailsRepository = EmailsRepositoryImpl()) : ViewModel() { + + // UI state exposed to the UI + private val _uiState = MutableStateFlow(ReplyHomeUIState(loading = true)) + val uiState: StateFlow = _uiState + + init { + observeEmails() + } + + private fun observeEmails() { + viewModelScope.launch { + emailsRepository.getAllEmails() + .catch { ex -> + _uiState.value = ReplyHomeUIState(error = ex.message) + } + .collect { emails -> + // We set first email selected by default for first App launch in large-screens + _uiState.value = ReplyHomeUIState( + emails = emails, + openedEmail = emails.first(), + ) + } + } + } + + fun setOpenedEmail(emailId: Long, contentType: ReplyContentType) { + /** + * We only set isDetailOnlyOpen to true when it's only single pane layout + */ + val email = uiState.value.emails.find { it.id == emailId } + _uiState.value = _uiState.value.copy( + openedEmail = email, + isDetailOnlyOpen = contentType == ReplyContentType.SINGLE_PANE, + ) + } + + fun toggleSelectedEmail(emailId: Long) { + val currentSelection = uiState.value.selectedEmails + _uiState.value = _uiState.value.copy( + selectedEmails = if (currentSelection.contains(emailId)) + currentSelection.minus(emailId) else currentSelection.plus(emailId), + ) + } + + fun closeDetailScreen() { + _uiState.value = _uiState + .value.copy( + isDetailOnlyOpen = false, + openedEmail = _uiState.value.emails.first(), + ) + } +} + +data class ReplyHomeUIState( + val emails: List = emptyList(), + val selectedEmails: Set = emptySet(), + val openedEmail: Email? = null, + val isDetailOnlyOpen: Boolean = false, + val loading: Boolean = false, + val error: String? = null, +) diff --git a/Reply/app/src/main/java/com/example/reply/ui/ReplyListContent.kt b/Reply/app/src/main/java/com/example/reply/ui/ReplyListContent.kt new file mode 100644 index 0000000000..da3fc1c89a --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/ReplyListContent.kt @@ -0,0 +1,225 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.window.layout.DisplayFeature +import com.example.reply.R +import com.example.reply.data.Email +import com.example.reply.ui.components.EmailDetailAppBar +import com.example.reply.ui.components.ReplyDockedSearchBar +import com.example.reply.ui.components.ReplyEmailListItem +import com.example.reply.ui.components.ReplyEmailThreadItem +import com.example.reply.ui.utils.ReplyContentType +import com.example.reply.ui.utils.ReplyNavigationType +import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy +import com.google.accompanist.adaptive.TwoPane + +@Composable +fun ReplyInboxScreen( + contentType: ReplyContentType, + replyHomeUIState: ReplyHomeUIState, + navigationType: ReplyNavigationType, + displayFeatures: List, + closeDetailScreen: () -> Unit, + navigateToDetail: (Long, ReplyContentType) -> Unit, + toggleSelectedEmail: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + // When moving from LIST_AND_DETAIL page to LIST page clear the selection and user should see LIST screen. + LaunchedEffect(key1 = contentType) { + if (contentType == ReplyContentType.SINGLE_PANE && !replyHomeUIState.isDetailOnlyOpen) { + closeDetailScreen() + } + } + + val emailLazyListState = rememberLazyListState() + + // TODO: Show top app bar over full width of app when in multi-select mode + + if (contentType == ReplyContentType.DUAL_PANE) { + TwoPane( + first = { + ReplyEmailList( + emails = replyHomeUIState.emails, + openedEmail = replyHomeUIState.openedEmail, + selectedEmailIds = replyHomeUIState.selectedEmails, + toggleEmailSelection = toggleSelectedEmail, + emailLazyListState = emailLazyListState, + navigateToDetail = navigateToDetail, + ) + }, + second = { + ReplyEmailDetail( + email = replyHomeUIState.openedEmail ?: replyHomeUIState.emails.first(), + isFullScreen = false, + ) + }, + strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f, gapWidth = 16.dp), + displayFeatures = displayFeatures, + ) + } else { + Box(modifier = modifier.fillMaxSize()) { + ReplySinglePaneContent( + replyHomeUIState = replyHomeUIState, + toggleEmailSelection = toggleSelectedEmail, + emailLazyListState = emailLazyListState, + modifier = Modifier.fillMaxSize(), + closeDetailScreen = closeDetailScreen, + navigateToDetail = navigateToDetail, + ) + // When we have bottom navigation we show FAB at the bottom end. + if (navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) { + ExtendedFloatingActionButton( + text = { Text(text = stringResource(id = R.string.compose)) }, + icon = { + Icon(painter = painterResource(id = R.drawable.ic_edit), contentDescription = stringResource(id = R.string.compose)) + }, + onClick = { /*TODO*/ }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + expanded = emailLazyListState.lastScrolledBackward || + !emailLazyListState.canScrollBackward, + ) + } + } + } +} + +@Composable +fun ReplySinglePaneContent( + replyHomeUIState: ReplyHomeUIState, + toggleEmailSelection: (Long) -> Unit, + emailLazyListState: LazyListState, + modifier: Modifier = Modifier, + closeDetailScreen: () -> Unit, + navigateToDetail: (Long, ReplyContentType) -> Unit, +) { + if (replyHomeUIState.openedEmail != null && replyHomeUIState.isDetailOnlyOpen) { + BackHandler { + closeDetailScreen() + } + ReplyEmailDetail(email = replyHomeUIState.openedEmail) { + closeDetailScreen() + } + } else { + ReplyEmailList( + emails = replyHomeUIState.emails, + openedEmail = replyHomeUIState.openedEmail, + selectedEmailIds = replyHomeUIState.selectedEmails, + toggleEmailSelection = toggleEmailSelection, + emailLazyListState = emailLazyListState, + modifier = modifier, + navigateToDetail = navigateToDetail, + ) + } +} + +@Composable +fun ReplyEmailList( + emails: List, + openedEmail: Email?, + selectedEmailIds: Set, + toggleEmailSelection: (Long) -> Unit, + emailLazyListState: LazyListState, + modifier: Modifier = Modifier, + navigateToDetail: (Long, ReplyContentType) -> Unit, +) { + Box(modifier = modifier.windowInsetsPadding(WindowInsets.statusBars)) { + ReplyDockedSearchBar( + emails = emails, + onSearchItemSelected = { searchedEmail -> + navigateToDetail(searchedEmail.id, ReplyContentType.SINGLE_PANE) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + ) + + LazyColumn( + modifier = modifier + .fillMaxWidth() + .padding(top = 80.dp), + state = emailLazyListState, + ) { + items(items = emails, key = { it.id }) { email -> + ReplyEmailListItem( + email = email, + navigateToDetail = { emailId -> + navigateToDetail(emailId, ReplyContentType.SINGLE_PANE) + }, + toggleSelection = toggleEmailSelection, + isOpened = openedEmail?.id == email.id, + isSelected = selectedEmailIds.contains(email.id), + ) + } + // Add extra spacing at the bottom if + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } + } + } +} + +@Composable +fun ReplyEmailDetail(email: Email, modifier: Modifier = Modifier, isFullScreen: Boolean = true, onBackPressed: () -> Unit = {}) { + LazyColumn( + modifier = modifier + .background(MaterialTheme.colorScheme.inverseOnSurface), + ) { + item { + EmailDetailAppBar(email, isFullScreen) { + onBackPressed() + } + } + items(items = email.threads, key = { it.id }) { email -> + ReplyEmailThreadItem(email = email) + } + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } + } +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/components/ReplyAppBars.kt b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyAppBars.kt new file mode 100644 index 0000000000..725228972c --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyAppBars.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.DockedSearchBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.reply.R +import com.example.reply.data.Email + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReplyDockedSearchBar(emails: List, onSearchItemSelected: (Email) -> Unit, modifier: Modifier = Modifier) { + var query by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + val searchResults = remember { mutableStateListOf() } + val onExpandedChange: (Boolean) -> Unit = { + expanded = it + } + + LaunchedEffect(query) { + searchResults.clear() + if (query.isNotEmpty()) { + searchResults.addAll( + emails.filter { + it.subject.startsWith( + prefix = query, + ignoreCase = true, + ) || + it.sender.fullName.startsWith( + prefix = + query, + ignoreCase = true, + ) + }, + ) + } + } + + DockedSearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = { + query = it + }, + onSearch = { expanded = false }, + expanded = expanded, + onExpandedChange = onExpandedChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(text = stringResource(id = R.string.search_emails)) }, + leadingIcon = { + if (expanded) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(id = R.string.back_button), + modifier = Modifier + .padding(start = 16.dp) + .clickable { + expanded = false + query = "" + }, + ) + } else { + Icon( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = stringResource(id = R.string.search), + modifier = Modifier.padding(start = 16.dp), + ) + } + }, + trailingIcon = { + ReplyProfileImage( + drawableResource = R.drawable.avatar_6, + description = stringResource(id = R.string.profile), + modifier = Modifier + .padding(12.dp) + .size(32.dp), + ) + }, + ) + }, + expanded = expanded, + onExpandedChange = onExpandedChange, + modifier = modifier, + content = { + if (searchResults.isNotEmpty()) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(items = searchResults, key = { it.id }) { email -> + ListItem( + headlineContent = { Text(email.subject) }, + supportingContent = { Text(email.sender.fullName) }, + leadingContent = { + ReplyProfileImage( + drawableResource = email.sender.avatar, + description = stringResource(id = R.string.profile), + modifier = Modifier + .size(32.dp), + ) + }, + modifier = Modifier.clickable { + onSearchItemSelected.invoke(email) + query = "" + expanded = false + }, + ) + } + } + } else if (query.isNotEmpty()) { + Text( + text = stringResource(id = R.string.no_item_found), + modifier = Modifier.padding(16.dp), + ) + } else + Text( + text = stringResource(id = R.string.no_search_history), + modifier = Modifier.padding(16.dp), + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmailDetailAppBar(email: Email, isFullScreen: Boolean, modifier: Modifier = Modifier, onBackPressed: () -> Unit) { + TopAppBar( + modifier = modifier, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.inverseOnSurface, + ), + title = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (isFullScreen) Alignment.CenterHorizontally + else Alignment.Start, + ) { + Text( + text = email.subject, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = "${email.threads.size} ${stringResource(id = R.string.messages)}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + }, + navigationIcon = { + if (isFullScreen) { + FilledIconButton( + onClick = onBackPressed, + modifier = Modifier.padding(8.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = stringResource(id = R.string.back_button), + modifier = Modifier.size(14.dp), + ) + } + } + }, + actions = { + IconButton( + onClick = { /*TODO*/ }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_more_vert), + contentDescription = stringResource(id = R.string.more_options_button), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/components/ReplyEmailListItem.kt b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyEmailListItem.kt new file mode 100644 index 0000000000..aa74634ad8 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyEmailListItem.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.reply.R +import com.example.reply.data.Email + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ReplyEmailListItem( + email: Email, + navigateToDetail: (Long) -> Unit, + toggleSelection: (Long) -> Unit, + modifier: Modifier = Modifier, + isOpened: Boolean = false, + isSelected: Boolean = false, +) { + Card( + modifier = modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .semantics { selected = isSelected } + .clip(CardDefaults.shape) + .combinedClickable( + onClick = { navigateToDetail(email.id) }, + onLongClick = { toggleSelection(email.id) }, + ) + .clip(CardDefaults.shape), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer + else if (isOpened) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + val clickModifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { toggleSelection(email.id) } + AnimatedContent(targetState = isSelected, label = "avatar") { + if (it) { + SelectedProfileImage(clickModifier) + } else { + ReplyProfileImage( + email.sender.avatar, + email.sender.fullName, + clickModifier, + ) + } + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = email.sender.firstName, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = email.createdAt, + style = MaterialTheme.typography.labelMedium, + ) + } + IconButton( + onClick = { /*TODO*/ }, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_star_border), + contentDescription = "Favorite", + tint = MaterialTheme.colorScheme.outline, + ) + } + } + + Text( + text = email.subject, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), + ) + Text( + text = email.body, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun SelectedProfileImage(modifier: Modifier = Modifier) { + Box( + modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .align(Alignment.Center), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/components/ReplyEmailThreadItem.kt b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyEmailThreadItem.kt new file mode 100644 index 0000000000..294e70ff3a --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyEmailThreadItem.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.reply.R +import com.example.reply.data.Email + +@Composable +fun ReplyEmailThreadItem(email: Email, modifier: Modifier = Modifier) { + Card( + modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + ReplyProfileImage( + drawableResource = email.sender.avatar, + description = email.sender.fullName, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = email.sender.firstName, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = "20 mins ago", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + IconButton( + onClick = { /*TODO*/ }, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_star_border), + contentDescription = "Favorite", + tint = MaterialTheme.colorScheme.outline, + ) + } + } + + Text( + text = email.subject, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), + ) + + Text( + text = email.body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = { /*TODO*/ }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + ), + ) { + Text( + text = stringResource(id = R.string.reply), + color = MaterialTheme.colorScheme.onSurface, + ) + } + Button( + onClick = { /*TODO*/ }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + ), + ) { + Text( + text = stringResource(id = R.string.reply_all), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/components/ReplyProfileImage.kt b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyProfileImage.kt new file mode 100644 index 0000000000..93d985dcc5 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyProfileImage.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +@Composable +fun ReplyProfileImage(drawableResource: Int, description: String, modifier: Modifier = Modifier) { + Image( + modifier = modifier + .size(40.dp) + .clip(CircleShape), + painter = painterResource(id = drawableResource), + contentDescription = description, + ) +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationActions.kt b/Reply/app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationActions.kt new file mode 100644 index 0000000000..ad0889c140 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationActions.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.navigation + +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import com.example.reply.R +import kotlinx.serialization.Serializable + +sealed interface Route { + @Serializable data object Inbox : Route + @Serializable data object Articles : Route + @Serializable data object DirectMessages : Route + @Serializable data object Groups : Route +} + +data class ReplyTopLevelDestination(val route: Route, val selectedIcon: Int, val unselectedIcon: Int, val iconTextId: Int) + +class ReplyNavigationActions(private val navController: NavHostController) { + + fun navigateTo(destination: ReplyTopLevelDestination) { + navController.navigate(destination.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + } +} + +val TOP_LEVEL_DESTINATIONS = listOf( + ReplyTopLevelDestination( + route = Route.Inbox, + selectedIcon = R.drawable.ic_inbox, + unselectedIcon = R.drawable.ic_inbox, + iconTextId = R.string.tab_inbox, + ), + ReplyTopLevelDestination( + route = Route.Articles, + selectedIcon = R.drawable.ic_article, + unselectedIcon = R.drawable.ic_article, + iconTextId = R.string.tab_article, + ), + ReplyTopLevelDestination( + route = Route.DirectMessages, + selectedIcon = R.drawable.ic_chat_bubble_outline, + unselectedIcon = R.drawable.ic_chat_bubble_outline, + iconTextId = R.string.tab_dm, + ), + ReplyTopLevelDestination( + route = Route.Groups, + selectedIcon = R.drawable.ic_group, + unselectedIcon = R.drawable.ic_group, + iconTextId = R.string.tab_groups, + ), + +) diff --git a/Reply/app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationComponents.kt b/Reply/app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationComponents.kt new file mode 100644 index 0000000000..8a19adf117 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationComponents.kt @@ -0,0 +1,478 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.PermanentDrawerSheet +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.currentWindowSize +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldLayout +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.unit.toSize +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.window.core.layout.WindowSizeClass +import com.example.reply.R +import com.example.reply.ui.utils.ReplyNavigationContentPosition +import kotlinx.coroutines.launch + +private fun WindowSizeClass.isCompact() = !isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) || + !isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND) + +class ReplyNavSuiteScope(val navSuiteType: NavigationSuiteType) + +@Composable +fun ReplyNavigationWrapper( + currentDestination: NavDestination?, + navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit, + content: @Composable ReplyNavSuiteScope.() -> Unit, +) { + val adaptiveInfo = currentWindowAdaptiveInfo() + val windowSize = with(LocalDensity.current) { + currentWindowSize().toSize().toDpSize() + } + + val navLayoutType = when { + adaptiveInfo.windowPosture.isTabletop -> NavigationSuiteType.NavigationBar + + adaptiveInfo.windowSizeClass.isCompact() -> NavigationSuiteType.NavigationBar + + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) && + windowSize.width >= 1200.dp -> NavigationSuiteType.NavigationDrawer + + else -> NavigationSuiteType.NavigationRail + } + val navContentPosition = if (adaptiveInfo.windowSizeClass.isHeightAtLeastBreakpoint(WindowSizeClass.HEIGHT_DP_MEDIUM_LOWER_BOUND)) { + ReplyNavigationContentPosition.CENTER + } else { + ReplyNavigationContentPosition.TOP + } + + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val coroutineScope = rememberCoroutineScope() + // Avoid opening the modal drawer when there is a permanent drawer or a bottom nav bar, + // but always allow closing an open drawer. + val gesturesEnabled = + drawerState.isOpen || navLayoutType == NavigationSuiteType.NavigationRail + + BackHandler(enabled = drawerState.isOpen) { + coroutineScope.launch { + drawerState.close() + } + } + + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = gesturesEnabled, + drawerContent = { + ModalNavigationDrawerContent( + currentDestination = currentDestination, + navigationContentPosition = navContentPosition, + navigateToTopLevelDestination = navigateToTopLevelDestination, + onDrawerClicked = { + coroutineScope.launch { + drawerState.close() + } + }, + ) + }, + ) { + NavigationSuiteScaffoldLayout( + layoutType = navLayoutType, + navigationSuite = { + when (navLayoutType) { + NavigationSuiteType.NavigationBar -> ReplyBottomNavigationBar( + currentDestination = currentDestination, + navigateToTopLevelDestination = navigateToTopLevelDestination, + ) + + NavigationSuiteType.NavigationRail -> ReplyNavigationRail( + currentDestination = currentDestination, + navigationContentPosition = navContentPosition, + navigateToTopLevelDestination = navigateToTopLevelDestination, + onDrawerClicked = { + coroutineScope.launch { + drawerState.open() + } + }, + ) + + NavigationSuiteType.NavigationDrawer -> PermanentNavigationDrawerContent( + currentDestination = currentDestination, + navigationContentPosition = navContentPosition, + navigateToTopLevelDestination = navigateToTopLevelDestination, + ) + } + }, + ) { + ReplyNavSuiteScope(navLayoutType).content() + } + } +} + +@Composable +fun ReplyNavigationRail( + currentDestination: NavDestination?, + navigationContentPosition: ReplyNavigationContentPosition, + navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit, + onDrawerClicked: () -> Unit = {}, +) { + NavigationRail( + modifier = Modifier.fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.inverseOnSurface, + ) { + Column( + modifier = Modifier.layoutId(LayoutType.HEADER), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + NavigationRailItem( + selected = false, + onClick = onDrawerClicked, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_menu), + contentDescription = stringResource(id = R.string.navigation_drawer), + ) + }, + ) + FloatingActionButton( + onClick = { /*TODO*/ }, + modifier = Modifier.padding(top = 8.dp, bottom = 32.dp), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_edit), + contentDescription = stringResource(id = R.string.compose), + modifier = Modifier.size(18.dp), + ) + } + Spacer(Modifier.height(8.dp)) // NavigationRailHeaderPadding + Spacer(Modifier.height(4.dp)) // NavigationRailVerticalPadding + } + + Column( + modifier = Modifier.layoutId(LayoutType.CONTENT), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TOP_LEVEL_DESTINATIONS.forEach { replyDestination -> + NavigationRailItem( + selected = currentDestination.hasRoute(replyDestination), + onClick = { navigateToTopLevelDestination(replyDestination) }, + icon = { + Icon( + painter = painterResource(id = replyDestination.selectedIcon), + contentDescription = stringResource( + id = replyDestination.iconTextId, + ), + ) + }, + ) + } + } + } +} + +@Composable +fun ReplyBottomNavigationBar(currentDestination: NavDestination?, navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit) { + NavigationBar(modifier = Modifier.fillMaxWidth()) { + TOP_LEVEL_DESTINATIONS.forEach { replyDestination -> + NavigationBarItem( + selected = currentDestination.hasRoute(replyDestination), + onClick = { navigateToTopLevelDestination(replyDestination) }, + icon = { + Icon( + painter = painterResource(id = replyDestination.selectedIcon), + contentDescription = stringResource(id = replyDestination.iconTextId), + ) + }, + ) + } + } +} + +@Composable +fun PermanentNavigationDrawerContent( + currentDestination: NavDestination?, + navigationContentPosition: ReplyNavigationContentPosition, + navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit, +) { + PermanentDrawerSheet( + modifier = Modifier.sizeIn(minWidth = 200.dp, maxWidth = 300.dp), + drawerContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + // TODO remove custom nav drawer content positioning when NavDrawer component supports it. ticket : b/232495216 + Layout( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(16.dp), + content = { + Column( + modifier = Modifier.layoutId(LayoutType.HEADER), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + modifier = Modifier + .padding(16.dp), + text = stringResource(id = R.string.app_name).uppercase(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + ExtendedFloatingActionButton( + onClick = { /*TODO*/ }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 40.dp), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_edit), + contentDescription = stringResource(id = R.string.compose), + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(id = R.string.compose), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) + } + } + + Column( + modifier = Modifier + .layoutId(LayoutType.CONTENT) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TOP_LEVEL_DESTINATIONS.forEach { replyDestination -> + NavigationDrawerItem( + selected = currentDestination.hasRoute(replyDestination), + label = { + Text( + text = stringResource(id = replyDestination.iconTextId), + modifier = Modifier.padding(horizontal = 16.dp), + ) + }, + icon = { + Icon( + painter = painterResource(id = replyDestination.selectedIcon), + contentDescription = stringResource( + id = replyDestination.iconTextId, + ), + ) + }, + colors = NavigationDrawerItemDefaults.colors( + unselectedContainerColor = Color.Transparent, + ), + onClick = { navigateToTopLevelDestination(replyDestination) }, + ) + } + } + }, + measurePolicy = navigationMeasurePolicy(navigationContentPosition), + ) + } +} + +@Composable +fun ModalNavigationDrawerContent( + currentDestination: NavDestination?, + navigationContentPosition: ReplyNavigationContentPosition, + navigateToTopLevelDestination: (ReplyTopLevelDestination) -> Unit, + onDrawerClicked: () -> Unit = {}, +) { + ModalDrawerSheet { + // TODO remove custom nav drawer content positioning when NavDrawer component supports it. ticket : b/232495216 + Layout( + modifier = Modifier + .background(MaterialTheme.colorScheme.inverseOnSurface) + .padding(16.dp), + content = { + Column( + modifier = Modifier.layoutId(LayoutType.HEADER), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.app_name).uppercase(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + IconButton(onClick = onDrawerClicked) { + Icon( + painter = painterResource(id = R.drawable.ic_menu_open), + contentDescription = stringResource(id = R.string.close_drawer), + ) + } + } + + ExtendedFloatingActionButton( + onClick = { /*TODO*/ }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 40.dp), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_edit), + contentDescription = stringResource(id = R.string.compose), + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(id = R.string.compose), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) + } + } + + Column( + modifier = Modifier + .layoutId(LayoutType.CONTENT) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TOP_LEVEL_DESTINATIONS.forEach { replyDestination -> + NavigationDrawerItem( + selected = currentDestination.hasRoute(replyDestination), + label = { + Text( + text = stringResource(id = replyDestination.iconTextId), + modifier = Modifier.padding(horizontal = 16.dp), + ) + }, + icon = { + Icon( + painter = painterResource(id = replyDestination.selectedIcon), + contentDescription = stringResource( + id = replyDestination.iconTextId, + ), + ) + }, + colors = NavigationDrawerItemDefaults.colors( + unselectedContainerColor = Color.Transparent, + ), + onClick = { navigateToTopLevelDestination(replyDestination) }, + ) + } + } + }, + measurePolicy = navigationMeasurePolicy(navigationContentPosition), + ) + } +} + +fun navigationMeasurePolicy(navigationContentPosition: ReplyNavigationContentPosition): MeasurePolicy { + return MeasurePolicy { measurables, constraints -> + lateinit var headerMeasurable: Measurable + lateinit var contentMeasurable: Measurable + measurables.forEach { + when (it.layoutId) { + LayoutType.HEADER -> headerMeasurable = it + LayoutType.CONTENT -> contentMeasurable = it + else -> error("Unknown layoutId encountered!") + } + } + + val headerPlaceable = headerMeasurable.measure(constraints) + val contentPlaceable = contentMeasurable.measure( + constraints.offset(vertical = -headerPlaceable.height), + ) + layout(constraints.maxWidth, constraints.maxHeight) { + // Place the header, this goes at the top + headerPlaceable.placeRelative(0, 0) + + // Determine how much space is not taken up by the content + val nonContentVerticalSpace = constraints.maxHeight - contentPlaceable.height + + val contentPlaceableY = when (navigationContentPosition) { + // Figure out the place we want to place the content, with respect to the + // parent (ignoring the header for now) + ReplyNavigationContentPosition.TOP -> 0 + + ReplyNavigationContentPosition.CENTER -> nonContentVerticalSpace / 2 + } + // And finally, make sure we don't overlap with the header. + .coerceAtLeast(headerPlaceable.height) + + contentPlaceable.placeRelative(0, contentPlaceableY) + } + } +} + +enum class LayoutType { + HEADER, + CONTENT, +} + +fun NavDestination?.hasRoute(destination: ReplyTopLevelDestination): Boolean = this?.hasRoute(destination.route::class) ?: false diff --git a/Reply/app/src/main/java/com/example/reply/ui/theme/Color.kt b/Reply/app/src/main/java/com/example/reply/ui/theme/Color.kt new file mode 100644 index 0000000000..e6c03db06b --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/theme/Color.kt @@ -0,0 +1,237 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.theme +import androidx.compose.ui.graphics.Color + +// Generate them via theme builder +// https://material-foundation.github.io/material-theme-builder/#/custom + +val primaryLight = Color(0xFF805610) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFFFDDB3) +val onPrimaryContainerLight = Color(0xFF291800) +val secondaryLight = Color(0xFF6F5B40) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFFBDEBC) +val onSecondaryContainerLight = Color(0xFF271904) +val tertiaryLight = Color(0xFF51643F) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFD4EABB) +val onTertiaryContainerLight = Color(0xFF102004) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF410002) +val backgroundLight = Color(0xFFFFF8F4) +val onBackgroundLight = Color(0xFF201B13) +val surfaceLight = Color(0xFFFFF8F4) +val onSurfaceLight = Color(0xFF201B13) +val surfaceVariantLight = Color(0xFFF0E0CF) +val onSurfaceVariantLight = Color(0xFF4F4539) +val outlineLight = Color(0xFF817567) +val outlineVariantLight = Color(0xFFD3C4B4) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF362F27) +val inverseOnSurfaceLight = Color(0xFFFCEFE2) +val inversePrimaryLight = Color(0xFFF4BD6F) +val surfaceDimLight = Color(0xFFE4D8CC) +val surfaceBrightLight = Color(0xFFFFF8F4) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFFFF1E5) +val surfaceContainerLight = Color(0xFFF9ECDF) +val surfaceContainerHighLight = Color(0xFFF3E6DA) +val surfaceContainerHighestLight = Color(0xFFEDE0D4) + +val primaryLightMediumContrast = Color(0xFF5D3C00) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF996C26) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF524027) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF877155) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF364826) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF677B54) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF8C0009) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFDA342E) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFFF8F4) +val onBackgroundLightMediumContrast = Color(0xFF201B13) +val surfaceLightMediumContrast = Color(0xFFFFF8F4) +val onSurfaceLightMediumContrast = Color(0xFF201B13) +val surfaceVariantLightMediumContrast = Color(0xFFF0E0CF) +val onSurfaceVariantLightMediumContrast = Color(0xFF4B4135) +val outlineLightMediumContrast = Color(0xFF685D50) +val outlineVariantLightMediumContrast = Color(0xFF85796B) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF362F27) +val inverseOnSurfaceLightMediumContrast = Color(0xFFFCEFE2) +val inversePrimaryLightMediumContrast = Color(0xFFF4BD6F) +val surfaceDimLightMediumContrast = Color(0xFFE4D8CC) +val surfaceBrightLightMediumContrast = Color(0xFFFFF8F4) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFFFF1E5) +val surfaceContainerLightMediumContrast = Color(0xFFF9ECDF) +val surfaceContainerHighLightMediumContrast = Color(0xFFF3E6DA) +val surfaceContainerHighestLightMediumContrast = Color(0xFFEDE0D4) + +val primaryLightHighContrast = Color(0xFF321E00) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF5D3C00) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF2E1F09) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF524027) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF172608) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF364826) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF4E0002) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF8C0009) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFFF8F4) +val onBackgroundLightHighContrast = Color(0xFF201B13) +val surfaceLightHighContrast = Color(0xFFFFF8F4) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFF0E0CF) +val onSurfaceVariantLightHighContrast = Color(0xFF2B2318) +val outlineLightHighContrast = Color(0xFF4B4135) +val outlineVariantLightHighContrast = Color(0xFF4B4135) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF362F27) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFFFE9CF) +val surfaceDimLightHighContrast = Color(0xFFE4D8CC) +val surfaceBrightLightHighContrast = Color(0xFFFFF8F4) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFFFF1E5) +val surfaceContainerLightHighContrast = Color(0xFFF9ECDF) +val surfaceContainerHighLightHighContrast = Color(0xFFF3E6DA) +val surfaceContainerHighestLightHighContrast = Color(0xFFEDE0D4) + +val primaryDark = Color(0xFFF4BD6F) +val onPrimaryDark = Color(0xFF452B00) +val primaryContainerDark = Color(0xFF633F00) +val onPrimaryContainerDark = Color(0xFFFFDDB3) +val secondaryDark = Color(0xFFDDC2A1) +val onSecondaryDark = Color(0xFF3E2D16) +val secondaryContainerDark = Color(0xFF56442A) +val onSecondaryContainerDark = Color(0xFFFBDEBC) +val tertiaryDark = Color(0xFFB8CEA1) +val onTertiaryDark = Color(0xFF243515) +val tertiaryContainerDark = Color(0xFF3A4C2A) +val onTertiaryContainerDark = Color(0xFFD4EABB) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF18120B) +val onBackgroundDark = Color(0xFFEDE0D4) +val surfaceDark = Color(0xFF18120B) +val onSurfaceDark = Color(0xFFEDE0D4) +val surfaceVariantDark = Color(0xFF4F4539) +val onSurfaceVariantDark = Color(0xFFD3C4B4) +val outlineDark = Color(0xFF9C8F80) +val outlineVariantDark = Color(0xFF4F4539) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFEDE0D4) +val inverseOnSurfaceDark = Color(0xFF362F27) +val inversePrimaryDark = Color(0xFF805610) +val surfaceDimDark = Color(0xFF18120B) +val surfaceBrightDark = Color(0xFF3F3830) +val surfaceContainerLowestDark = Color(0xFF120D07) +val surfaceContainerLowDark = Color(0xFF201B13) +val surfaceContainerDark = Color(0xFF251F17) +val surfaceContainerHighDark = Color(0xFF2F2921) +val surfaceContainerHighestDark = Color(0xFF3B342B) + +val primaryDarkMediumContrast = Color(0xFFF9C172) +val onPrimaryDarkMediumContrast = Color(0xFF221300) +val primaryContainerDarkMediumContrast = Color(0xFFB9883F) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFE2C6A5) +val onSecondaryDarkMediumContrast = Color(0xFF211402) +val secondaryContainerDarkMediumContrast = Color(0xFFA58D6F) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFBCD2A5) +val onTertiaryDarkMediumContrast = Color(0xFF0B1A01) +val tertiaryContainerDarkMediumContrast = Color(0xFF83976E) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFBAB1) +val onErrorDarkMediumContrast = Color(0xFF370001) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF18120B) +val onBackgroundDarkMediumContrast = Color(0xFFEDE0D4) +val surfaceDarkMediumContrast = Color(0xFF18120B) +val onSurfaceDarkMediumContrast = Color(0xFFFFFAF7) +val surfaceVariantDarkMediumContrast = Color(0xFF4F4539) +val onSurfaceVariantDarkMediumContrast = Color(0xFFD7C8B8) +val outlineDarkMediumContrast = Color(0xFFAEA192) +val outlineVariantDarkMediumContrast = Color(0xFF8E8173) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFEDE0D4) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF302921) +val inversePrimaryDarkMediumContrast = Color(0xFF644100) +val surfaceDimDarkMediumContrast = Color(0xFF18120B) +val surfaceBrightDarkMediumContrast = Color(0xFF3F3830) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF120D07) +val surfaceContainerLowDarkMediumContrast = Color(0xFF201B13) +val surfaceContainerDarkMediumContrast = Color(0xFF251F17) +val surfaceContainerHighDarkMediumContrast = Color(0xFF2F2921) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3B342B) + +val primaryDarkHighContrast = Color(0xFFFFFAF7) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFF9C172) +val onPrimaryContainerDarkHighContrast = Color(0xFF000000) +val secondaryDarkHighContrast = Color(0xFFFFFAF7) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFE2C6A5) +val onSecondaryContainerDarkHighContrast = Color(0xFF000000) +val tertiaryDarkHighContrast = Color(0xFFF3FFE2) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFBCD2A5) +val onTertiaryContainerDarkHighContrast = Color(0xFF000000) +val errorDarkHighContrast = Color(0xFFFFF9F9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFBAB1) +val onErrorContainerDarkHighContrast = Color(0xFF000000) +val backgroundDarkHighContrast = Color(0xFF18120B) +val onBackgroundDarkHighContrast = Color(0xFFEDE0D4) +val surfaceDarkHighContrast = Color(0xFF18120B) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF4F4539) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFAF7) +val outlineDarkHighContrast = Color(0xFFD7C8B8) +val outlineVariantDarkHighContrast = Color(0xFFD7C8B8) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFEDE0D4) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF3C2500) +val surfaceDimDarkHighContrast = Color(0xFF18120B) +val surfaceBrightDarkHighContrast = Color(0xFF3F3830) +val surfaceContainerLowestDarkHighContrast = Color(0xFF120D07) +val surfaceContainerLowDarkHighContrast = Color(0xFF201B13) +val surfaceContainerDarkHighContrast = Color(0xFF251F17) +val surfaceContainerHighDarkHighContrast = Color(0xFF2F2921) +val surfaceContainerHighestDarkHighContrast = Color(0xFF3B342B) diff --git a/Reply/app/src/main/java/com/example/reply/ui/theme/Shapes.kt b/Reply/app/src/main/java/com/example/reply/ui/theme/Shapes.kt new file mode 100644 index 0000000000..89075e7166 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/theme/Shapes.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val shapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(32.dp), +) diff --git a/Reply/app/src/main/java/com/example/reply/ui/theme/Theme.kt b/Reply/app/src/main/java/com/example/reply/ui/theme/Theme.kt new file mode 100644 index 0000000000..0b9296e56d --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/theme/Theme.kt @@ -0,0 +1,317 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.theme +import android.app.Activity +import android.app.UiModeManager +import android.content.Context +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +fun isContrastAvailable(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE +} + +@Composable +fun selectSchemeForContrast(isDark: Boolean): ColorScheme { + val context = LocalContext.current + var colorScheme = if (isDark) darkScheme else lightScheme + val isPreview = LocalInspectionMode.current + // TODO(b/336693596): UIModeManager is not yet supported in preview + if (!isPreview && isContrastAvailable()) { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + val contrastLevel = uiModeManager.contrast + + colorScheme = when (contrastLevel) { + in 0.0f..0.33f -> if (isDark) + darkScheme else lightScheme + + in 0.34f..0.66f -> if (isDark) + mediumContrastDarkColorScheme else mediumContrastLightColorScheme + + in 0.67f..1.0f -> if (isDark) + highContrastDarkColorScheme else highContrastLightColorScheme + + else -> if (isDark) darkScheme else lightScheme + } + return colorScheme + } else return colorScheme +} +@Composable +fun ContrastAwareReplyTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: + @Composable() + () -> Unit, +) { + val replyColorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + else -> selectSchemeForContrast(darkTheme) + } + MaterialTheme( + colorScheme = replyColorScheme, + typography = replyTypography, + shapes = shapes, + content = content, + ) +} diff --git a/Reply/app/src/main/java/com/example/reply/ui/theme/Type.kt b/Reply/app/src/main/java/com/example/reply/ui/theme/Type.kt new file mode 100644 index 0000000000..769adde687 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/theme/Type.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Material 3 typography +val replyTypography = Typography( + headlineLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/Reply/app/src/main/java/com/example/reply/ui/utils/WindowStateUtils.kt b/Reply/app/src/main/java/com/example/reply/ui/utils/WindowStateUtils.kt new file mode 100644 index 0000000000..1e61dae3b7 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/utils/WindowStateUtils.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.reply.ui.utils + +import android.graphics.Rect +import androidx.window.layout.FoldingFeature +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +/** + * Information about the posture of the device + */ +sealed interface DevicePosture { + object NormalPosture : DevicePosture + + data class BookPosture(val hingePosition: Rect) : DevicePosture + + data class Separating(val hingePosition: Rect, var orientation: FoldingFeature.Orientation) : DevicePosture +} + +@OptIn(ExperimentalContracts::class) +fun isBookPosture(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.HALF_OPENED && + foldFeature.orientation == FoldingFeature.Orientation.VERTICAL +} + +@OptIn(ExperimentalContracts::class) +fun isSeparating(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating +} + +/** + * Different type of navigation supported by app depending on device size and state. + */ +enum class ReplyNavigationType { + BOTTOM_NAVIGATION, + NAVIGATION_RAIL, + PERMANENT_NAVIGATION_DRAWER, +} + +/** + * Different position of navigation content inside Navigation Rail, Navigation Drawer depending on device size and state. + */ +enum class ReplyNavigationContentPosition { + TOP, + CENTER, +} + +/** + * App Content shown depending on device size and state. + */ +enum class ReplyContentType { + SINGLE_PANE, + DUAL_PANE, +} diff --git a/Reply/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/Reply/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..5a1e589eb1 --- /dev/null +++ b/Reply/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/Reply/app/src/main/res/drawable/avatar_0.jpg b/Reply/app/src/main/res/drawable/avatar_0.jpg new file mode 100644 index 0000000000..dcf2608a88 Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_0.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_1.jpg b/Reply/app/src/main/res/drawable/avatar_1.jpg new file mode 100644 index 0000000000..23f171d482 Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_1.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_10.jpg b/Reply/app/src/main/res/drawable/avatar_10.jpg new file mode 100644 index 0000000000..27b8dc6152 Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_10.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_2.jpg b/Reply/app/src/main/res/drawable/avatar_2.jpg new file mode 100644 index 0000000000..54c74a8880 Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_2.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_3.jpg b/Reply/app/src/main/res/drawable/avatar_3.jpg new file mode 100644 index 0000000000..a63f8ce579 Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_3.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_4.jpg b/Reply/app/src/main/res/drawable/avatar_4.jpg new file mode 100644 index 0000000000..279b70def3 Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_4.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_5.jpg b/Reply/app/src/main/res/drawable/avatar_5.jpg new file mode 100644 index 0000000000..e4266c738d Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_5.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_6.jpg b/Reply/app/src/main/res/drawable/avatar_6.jpg new file mode 100644 index 0000000000..0b32267751 Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_6.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_7.jpg b/Reply/app/src/main/res/drawable/avatar_7.jpg new file mode 100644 index 0000000000..01e9a775be Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_7.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_8.jpg b/Reply/app/src/main/res/drawable/avatar_8.jpg new file mode 100644 index 0000000000..5b387afa2a Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_8.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_9.jpg b/Reply/app/src/main/res/drawable/avatar_9.jpg new file mode 100644 index 0000000000..087bf93af1 Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_9.jpg differ diff --git a/Reply/app/src/main/res/drawable/avatar_express.png b/Reply/app/src/main/res/drawable/avatar_express.png new file mode 100644 index 0000000000..f05790fb90 Binary files /dev/null and b/Reply/app/src/main/res/drawable/avatar_express.png differ diff --git a/Reply/app/src/main/res/drawable/ic_arrow_back.xml b/Reply/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000000..d04576b541 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_article.xml b/Reply/app/src/main/res/drawable/ic_article.xml new file mode 100644 index 0000000000..8f2ba45ee5 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_article.xml @@ -0,0 +1,10 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_chat_bubble_outline.xml b/Reply/app/src/main/res/drawable/ic_chat_bubble_outline.xml new file mode 100644 index 0000000000..e3a59efb29 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_chat_bubble_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_check.xml b/Reply/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000000..6f1fcce828 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_edit.xml b/Reply/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000000..db9b31f528 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_group.xml b/Reply/app/src/main/res/drawable/ic_group.xml new file mode 100644 index 0000000000..14d4bbcce5 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_group.xml @@ -0,0 +1,9 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_inbox.xml b/Reply/app/src/main/res/drawable/ic_inbox.xml new file mode 100644 index 0000000000..779147f618 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_inbox.xml @@ -0,0 +1,9 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_launcher_background.xml b/Reply/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..e009ebe7e1 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Reply/app/src/main/res/drawable/ic_menu.xml b/Reply/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000000..7915d80e52 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_menu_open.xml b/Reply/app/src/main/res/drawable/ic_menu_open.xml new file mode 100644 index 0000000000..4339190308 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_menu_open.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/Reply/app/src/main/res/drawable/ic_more_vert.xml b/Reply/app/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000000..59400ec977 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_search.xml b/Reply/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000000..20c7b4e734 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/Reply/app/src/main/res/drawable/ic_star_border.xml b/Reply/app/src/main/res/drawable/ic_star_border.xml new file mode 100644 index 0000000000..38f4240690 --- /dev/null +++ b/Reply/app/src/main/res/drawable/ic_star_border.xml @@ -0,0 +1,9 @@ + + + diff --git a/Reply/app/src/main/res/drawable/paris_1.jpg b/Reply/app/src/main/res/drawable/paris_1.jpg new file mode 100644 index 0000000000..b5835ed572 Binary files /dev/null and b/Reply/app/src/main/res/drawable/paris_1.jpg differ diff --git a/Reply/app/src/main/res/drawable/paris_2.jpg b/Reply/app/src/main/res/drawable/paris_2.jpg new file mode 100644 index 0000000000..da0bc53bd9 Binary files /dev/null and b/Reply/app/src/main/res/drawable/paris_2.jpg differ diff --git a/Reply/app/src/main/res/drawable/paris_3.jpg b/Reply/app/src/main/res/drawable/paris_3.jpg new file mode 100644 index 0000000000..2cad5a3671 Binary files /dev/null and b/Reply/app/src/main/res/drawable/paris_3.jpg differ diff --git a/Reply/app/src/main/res/drawable/paris_4.jpg b/Reply/app/src/main/res/drawable/paris_4.jpg new file mode 100644 index 0000000000..73151fa18b Binary files /dev/null and b/Reply/app/src/main/res/drawable/paris_4.jpg differ diff --git a/Reply/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Reply/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..c4a603d4cc --- /dev/null +++ b/Reply/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Reply/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Reply/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..c4a603d4cc --- /dev/null +++ b/Reply/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Reply/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Reply/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..5f0a7a6c52 Binary files /dev/null and b/Reply/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Reply/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/Reply/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..f1bd7819c9 Binary files /dev/null and b/Reply/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/Reply/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Reply/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..5c2df1066d Binary files /dev/null and b/Reply/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Reply/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Reply/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..5d99f0240f Binary files /dev/null and b/Reply/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Reply/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/Reply/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..1d607ab7aa Binary files /dev/null and b/Reply/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/Reply/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Reply/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..4280df4399 Binary files /dev/null and b/Reply/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Reply/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Reply/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..5852f5f70c Binary files /dev/null and b/Reply/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Reply/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/Reply/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..94864b5d79 Binary files /dev/null and b/Reply/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/Reply/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Reply/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..2e42a4bb35 Binary files /dev/null and b/Reply/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Reply/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Reply/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..7b0a45084c Binary files /dev/null and b/Reply/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Reply/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/Reply/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..7c7899b09d Binary files /dev/null and b/Reply/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/Reply/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Reply/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..9421a94db5 Binary files /dev/null and b/Reply/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Reply/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Reply/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..8298a7625a Binary files /dev/null and b/Reply/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Reply/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/Reply/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..1ced22c5da Binary files /dev/null and b/Reply/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/Reply/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Reply/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..2cdd493b37 Binary files /dev/null and b/Reply/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Reply/app/src/main/res/values/strings.xml b/Reply/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..86edc12043 --- /dev/null +++ b/Reply/app/src/main/res/values/strings.xml @@ -0,0 +1,39 @@ + + + Reply + Navigation Drawer + Close drawer + Inbox + Articles + Direct Messages + Groups + + Profile + Search + Reply + Reply All + + Edit + Compose + + Screen under construction + This screen is still under construction. This sample will help you learn about adaptive layouts in Jetpack Compose + + Back + More options + Messages + 4 hrs ago + + Search emails + No item found + No search history + diff --git a/Reply/app/src/main/res/values/themes.xml b/Reply/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..24ba646121 --- /dev/null +++ b/Reply/app/src/main/res/values/themes.xml @@ -0,0 +1,15 @@ + + + + +