diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..23200d42ea --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @android/compose-devrel 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 new file mode 100644 index 0000000000..9ba9603f34 --- /dev/null +++ b/.github/ci-gradle.properties @@ -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 +# +# 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. +# + +org.gradle.daemon=false +org.gradle.parallel=true +org.gradle.jvmargs=-Xmx5120m +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/JetLagged.yaml b/.github/workflows/JetLagged.yaml new file mode 100644 index 0000000000..d5d7f2d664 --- /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@v4 + + - 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@v4 + with: + java-version: 17 + distribution: 'zulu' + + - name: Generate cache key + run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt + + - uses: actions/cache@v4 + 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@v4 + 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 new file mode 100644 index 0000000000..a444e4e5d8 --- /dev/null +++ b/.github/workflows/JetNews.yaml @@ -0,0 +1,81 @@ +name: JetNews + +on: + push: + 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: + 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: ubuntu-latest + timeout-minutes: 30 + strategy: + matrix: + api-level: [23, 26, 29] + + steps: + - name: Checkout + uses: actions/checkout@v4 + - 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 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'zulu' + + - name: Generate cache key + run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt + + - uses: actions/cache@v4 + 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@v4 + with: + 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 new file mode 100644 index 0000000000..44c15fe1ce --- /dev/null +++ b/.github/workflows/Jetcaster.yaml @@ -0,0 +1,26 @@ +name: Jetcaster + +on: + push: + branches: + - main + paths: + - '.github/workflows/Jetcaster.yaml' + - 'Jetcaster/**' + pull_request: + paths: + - '.github/workflows/Jetcaster.yaml' + - 'Jetcaster/**' + workflow_dispatch: + +jobs: + build: + 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 new file mode 100644 index 0000000000..feb22ef6eb --- /dev/null +++ b/.github/workflows/Jetchat.yaml @@ -0,0 +1,77 @@ +name: Jetchat + +on: + push: + 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: + 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: ubuntu-latest + timeout-minutes: 30 + strategy: + matrix: + api-level: [23, 26, 29] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - 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@v4 + with: + java-version: 17 + distribution: 'zulu' + + - name: Generate cache key + run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt + + - uses: actions/cache@v4 + 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@v4 + with: + 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 new file mode 100644 index 0000000000..1962c2c789 --- /dev/null +++ b/.github/workflows/Jetsnack.yaml @@ -0,0 +1,25 @@ +name: Jetsnack + +on: + push: + branches: + - main + paths: + - '.github/workflows/Jetsnack.yaml' + - 'Jetsnack/**' + pull_request: + paths: + - '.github/workflows/Jetsnack.yaml' + - 'Jetsnack/**' + workflow_dispatch: + +jobs: + build: + 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/Release.yml b/.github/workflows/Release.yml new file mode 100644 index 0000000000..363c9fb837 --- /dev/null +++ b/.github/workflows/Release.yml @@ -0,0 +1,81 @@ +name: GitHub Release with APKs + +on: + 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 + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - 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@v4 + with: + 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 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/mobile/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 diff --git a/.github/workflows/Reply.yaml b/.github/workflows/Reply.yaml new file mode 100644 index 0000000000..569f7f49dc --- /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@v4 + + - 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@v4 + with: + java-version: 17 + distribution: 'zulu' + + - name: Generate cache key + run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt + + - uses: actions/cache@v4 + 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@v4 + 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..d4d65a9984 --- /dev/null +++ b/.github/workflows/build-sample.yml @@ -0,0 +1,91 @@ +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@v4 + + - 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@v4 + with: + java-version: 17 + distribution: 'zulu' + + - name: Generate cache key + run: ./scripts/checksum.sh ${{ inputs.path }} checksum.txt + + - uses: actions/cache@v4 + 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 --init-script buildscripts/init.gradle.kts 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@v4 + with: + name: build-outputs + path: ${{ inputs.path }}/${{ inputs.module }}/build/outputs + + - name: Upload build reports + if: always() + uses: actions/upload-artifact@v4 + 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 new file mode 100644 index 0000000000..46a0f90d3a --- /dev/null +++ b/.github/workflows/copy-branch.yml @@ -0,0 +1,31 @@ +# 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@v4 + 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/test-snapshot.yml b/.github/workflows/test-snapshot.yml new file mode 100644 index 0000000000..13183d3deb --- /dev/null +++ b/.github/workflows/test-snapshot.yml @@ -0,0 +1,49 @@ +name: Build and Test a Compose snapshot + +on: + workflow_dispatch: + inputs: + snapshotID: + required: true + type: string + composeVersion: + required: true + type: string +env: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: $${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: $${{ secrets.COMPOSE_KEY_PASSWORD }} +concurrency: + group: ${{ inputs.name }}-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - 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@v4 + with: + java-version: 17 + distribution: 'zulu' + + - name: Check snapshot + working-directory: ${{ inputs.path }} + run: ./scripts/test_snapshot.sh $CI_COMPOSE_VERSION $CI_COMPOSE_SNAPSHOT + env: + CI_COMPOSE_VERSION: ${{ inputs.composeVersion }} + CI_COMPOSE_SNAPSHOT: ${{ inputs.snapshotID }} + + - name: Upload build reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-reports + path: ${{ inputs.path }}/app/build/reports diff --git a/.github/workflows/update_deps.yml b/.github/workflows/update_deps.yml new file mode 100644 index 0000000000..e1720ec60d --- /dev/null +++ b/.github/workflows/update_deps.yml @@ -0,0 +1,40 @@ +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@v4 + - 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@v4 + with: + java-version: 17 + distribution: 'zulu' + cache: gradle + + - name: Update dependencies + run: ./scripts/updateDeps.sh + - name: Duplicate version configuration + run: ./scripts/duplicate_version_config.sh + - name: Create pull request + id: cpr + uses: peter-evans/create-pull-request@v7 + 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/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 b3ba6711f0..f23eb506de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Contributor License Agreements -We'd love to accept your sample apps and patches! Before we can take them, we +We'd love to accept your patches! Before we can take them, we have to jump a couple of legal hurdles. Please fill out either the individual or corporate Contributor License Agreement (CLA). @@ -18,6 +18,8 @@ accept your pull requests. ## Contributing A Patch +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. 1. If your proposed change is accepted, and you haven't already done so, sign a @@ -28,4 +30,4 @@ accept your pull requests. [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. +1. Submit a pull request targeting the `main` branch. 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/JetLagged/ASSETS_LICENSE b/JetLagged/ASSETS_LICENSE new file mode 100644 index 0000000000..e7fc95866c --- /dev/null +++ b/JetLagged/ASSETS_LICENSE @@ -0,0 +1,88 @@ +All font files are licensed under the SIL OPEN FONT LICENSE license. All other files are licensed under the Apache 2 license. + + +SIL OPEN FONT LICENSE +Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file 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/JetLagged/app/.gitignore b/JetLagged/app/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/JetLagged/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/JetLagged/app/build.gradle.kts b/JetLagged/app/build.gradle.kts new file mode 100644 index 0000000000..4103207250 --- /dev/null +++ b/JetLagged/app/build.gradle.kts @@ -0,0 +1,138 @@ +/* + * 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. + */ + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + 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 + } + } + kotlinOptions { + jvmTarget = "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.material.iconsExtended) + 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..6031878513 --- /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.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..fd9718b401 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/HomeScreenCards.kt @@ -0,0 +1,282 @@ +/* + * 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.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.material.icons.Icons +import androidx.compose.material.icons.filled.SingleBed +import androidx.compose.material.icons.filled.Watch +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.graphics.vector.ImageVector +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, + icon: ImageVector, + 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( + 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( + 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 = Icons.Default.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 = Icons.Default.SingleBed, + 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..9277d1af5f --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/JetLaggedDrawer.kt @@ -0,0 +1,274 @@ +/* + * 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.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.material.icons.Icons +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Leaderboard +import androidx.compose.material.icons.filled.Settings +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.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.platform.LocalDensity +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 (e: 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(imageVector = it.icon, contentDescription = it.text) + }, + selected = selectedScreen == it, + onClick = { + onScreenSelected(it) + }, + ) + } + } +} + +private val DrawerWidth = 300.dp + +private enum class Screen(val text: String, val icon: ImageVector) { + Home("Home", Icons.Default.Home), + SleepDetails("Sleep", Icons.Default.Bedtime), + Leaderboard("Leaderboard", Icons.Default.Leaderboard), + Settings("Settings", Icons.Default.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..05ce288c26 --- /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..5b6eb82054 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/BubbleBackground.kt @@ -0,0 +1,108 @@ +/* + * 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..b0f5154433 --- /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..57352fae3f --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/SimpleGradientBackground.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.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..ddeb8b3b00 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/SolarFlareShaderBackground.kt @@ -0,0 +1,164 @@ +/* + * 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, +) : DrawModifierNode, Modifier.Node() { + 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..bd6bff36d3 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/backgrounds/StripesShaderBackground.kt @@ -0,0 +1,175 @@ +/* + * 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, +) : DrawModifierNode, Modifier.Node() { + + 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..44a508a73c --- /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..830c5b2c7a --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/data/JetLaggedHomeScreenState.kt @@ -0,0 +1,36 @@ +/* + * 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..9fade11871 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/heartrate/HeartRateCard.kt @@ -0,0 +1,79 @@ +/* + * 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..e2435233bc --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/heartrate/HeartRateGraph.kt @@ -0,0 +1,255 @@ +/* + * 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..894e81d22d --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedHeader.kt @@ -0,0 +1,70 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +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.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( + onDrawerClicked: () -> Unit = {}, + modifier: Modifier = Modifier +) { + Box( + modifier.height(150.dp) + ) { + Row(modifier = Modifier.windowInsetsPadding(insets = WindowInsets.systemBars)) { + IconButton( + onClick = onDrawerClicked, + ) { + Icon( + Icons.Default.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..4ccb05f15b --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedHeaderTabs.kt @@ -0,0 +1,109 @@ +/* + * 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.ScrollableTabRow +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, +) { + ScrollableTabRow( + modifier = modifier, + edgePadding = 12.dp, + selectedTabIndex = selectedTab.ordinal, + indicator = { tabPositions: List -> + Box( + Modifier + .tabIndicatorOffset(tabPositions[selectedTab.ordinal]) + .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..15fe47207c --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/JetLaggedTimeGraph.kt @@ -0,0 +1,155 @@ +/* + * 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.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, Locale.getDefault() + ), + 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..557a5fcc87 --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/SleepBar.kt @@ -0,0 +1,370 @@ +/* + * 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..c955ced9ce --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/SleepData.kt @@ -0,0 +1,87 @@ +/* + * 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..a04415a45e --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/sleep/TimeGraph.kt @@ -0,0 +1,131 @@ +/* + * 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..c9fceeb98d --- /dev/null +++ b/JetLagged/app/src/main/java/com/example/jetlagged/ui/theme/Theme.kt @@ -0,0 +1,132 @@ +/* + * 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..02244d29d3 --- /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..760fda4b79 --- /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_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/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/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/spotless/copyright.kt b/Jetcaster/spotless/copyright.kt new file mode 100644 index 0000000000..806db0fb54 --- /dev/null +++ b/Jetcaster/spotless/copyright.kt @@ -0,0 +1,16 @@ +/* + * 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/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..3ff7ae7b46 --- /dev/null +++ b/Jetcaster/tv/build.gradle.kts @@ -0,0 +1,116 @@ +/* + * 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 + * + * 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.android.application) + alias(libs.plugins.kotlin.android) + 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" + ) + } + } + kotlinOptions { + jvmTarget = "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}" + } + } +} + + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material.iconsExtended) + 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) + + 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..1c978f952d --- /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..c5943815be --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/model/CategorySelection.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.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..f9f07236a5 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -0,0 +1,246 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.VideoLibrary +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.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.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 { + enter = { + 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(Icons.Default.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( + Icons.Default.Search, + contentDescription = null + ) + } + ) { + Text(text = "Search") + } + NavigationDrawerItem( + selected = isClosed && currentRoute == Screen.Discover.route, + onClick = jetcasterAppState::navigateToDiscover, + leadingContent = { + Icon( + Icons.Default.Home, + contentDescription = null + ) + }, + modifier = Modifier.focusRequester(discover) + ) { + Text(text = "Discover") + } + NavigationDrawerItem( + selected = isClosed && currentRoute == Screen.Library.route, + onClick = jetcasterAppState::navigateToLibrary, + leadingContent = { + Icon( + Icons.Default.VideoLibrary, + 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(Icons.Default.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..bc714c99a0 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -0,0 +1,140 @@ +/* + * 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..4cdd5ccb52 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Background.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.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..aeeaee4137 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Button.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.component + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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( + icon = Icons.Outlined.PlayArrow, + 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( + Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.label_add_playlist), + ) + } +} + +@Composable +internal fun InfoButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + Icons.Outlined.Info, + contentDescription = stringResource(R.string.label_info), + ) + } +} + +@Composable +internal fun PreviousButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + Icons.Default.SkipPrevious, + contentDescription = stringResource(R.string.label_previous_episode) + ) + } +} + +@Composable +internal fun NextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + Icons.Default.SkipNext, + contentDescription = stringResource(R.string.label_next_episode) + ) + } +} + +@Composable +internal fun PlayPauseButton( + isPlaying: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val (icon, description) = if (isPlaying) { + Icons.Default.Pause to stringResource(R.string.label_pause) + } else { + Icons.Default.PlayArrow 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( + Icons.Default.Replay10, + contentDescription = stringResource(R.string.label_rewind) + ) + } +} + +@Composable +internal fun SkipButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + Icons.Default.Forward10, + 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..b5fa71653c --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt @@ -0,0 +1,47 @@ +/* + * 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.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +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, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + scale: ButtonScale = ButtonDefaults.scale(), +) { + Button(onClick = onClick, modifier = modifier, scale = scale) { + Icon( + icon, + 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..649fbd5c5e --- /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 { + exit = { + focusRequester.saveFocusedChild() + FocusRequester.Default + } + enter = { + 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..ddde4bcb56 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeCard.kt @@ -0,0 +1,104 @@ +/* + * 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..0ce6dbeaf7 --- /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..01664adb9a --- /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..3861482cbb --- /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 { + enter = { + when { + lazyListState.layoutInfo.visibleItemsInfo.isEmpty() -> FocusRequester.Cancel + isSameList && focusRequester.restoreFocusedChild() -> FocusRequester.Cancel + else -> firstItem + } + } + exit = { + 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..be7f99cabd --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt @@ -0,0 +1,61 @@ +/* + * 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..4603497509 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt @@ -0,0 +1,227 @@ +/* + * 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..21575e4c25 --- /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..3524cae812 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/PodcastCard.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.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..c36c3c7fce --- /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..ba3046716b --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt @@ -0,0 +1,88 @@ +/* + * 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..94658ad170 --- /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..a0727cd559 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -0,0 +1,151 @@ +/* + * 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.collectAsState +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.navigation.compose.hiltViewModel +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.collectAsState() + + 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 { + enter = { + 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..925fd321b3 --- /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..b5b02abfb4 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt @@ -0,0 +1,161 @@ +/* + * 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.collectAsState +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.navigation.compose.hiltViewModel +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.collectAsState() + + 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..2a5bec06f2 --- /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..ed73883b0d --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -0,0 +1,126 @@ +/* + * 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.collectAsState +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.navigation.compose.hiltViewModel +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.collectAsState() + 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..3797b98e89 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.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.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..bf81680771 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -0,0 +1,480 @@ +/* + * 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.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.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.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.navigation.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.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, + ) + } + } +} + +@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) { + EpisodePlayerWithBackground( + playerEpisode = currentEpisode, + queue = EpisodeList(episodePlayerState.queue), + isPlaying = episodePlayerState.isPlaying, + timeElapsed = episodePlayerState.timeElapsed, + play = play, + pause = pause, + previous = previous, + next = next, + skip = skip, + rewind = rewind, + 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 } + ) + } +} 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..9b66a9359d --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreenViewModel.kt @@ -0,0 +1,83 @@ +/* + * 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..26e84b7dc8 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreen.kt @@ -0,0 +1,375 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.navigation.compose.hiltViewModel +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.collectAsState() + 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 icon = if (isSubscribed) { + Icons.Default.Remove + } else { + Icons.Default.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, + icon = icon, + 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..c68033c656 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastDetailsScreenViewModel.kt @@ -0,0 +1,126 @@ +/* + * 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..f4b7cd100c --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt @@ -0,0 +1,276 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +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.collectAsState() + + 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( + Icons.Default.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..d16d143b7b --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreenViewModel.kt @@ -0,0 +1,152 @@ +/* + * 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..53bf32f50c --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.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.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..def4a37865 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt @@ -0,0 +1,112 @@ +/* + * 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..f895300f78 --- /dev/null +++ b/Jetcaster/tv/src/main/java/com/example/jetcaster/tv/ui/theme/Theme.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.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..1be9cc97c1 --- /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_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_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/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/Jetchat/.gitignore b/Jetchat/.gitignore new file mode 100644 index 0000000000..834ecd9dff --- /dev/null +++ b/Jetchat/.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/Jetchat/.google/packaging.yaml b/Jetchat/.google/packaging.yaml new file mode 100644 index 0000000000..b8e5ccd387 --- /dev/null +++ b/Jetchat/.google/packaging.yaml @@ -0,0 +1,42 @@ +# 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, JetpackCompose] +categories: + - AndroidArchitectureUILayer + - AndroidArchitectureStateProduction + - AndroidArchitectureUIEvents + - JetpackComposeArchitectureAndState + - JetpackComposeDesignSystems + - JetpackComposeAnimation + - JetpackComposeTextAndInput + - JetpackComposeTesting +languages: [Kotlin] +solutions: + - Mobile + - JetpackHilt + - JetpackLifecycle + - JetpackNavigation + - JetpackFragment +github: android/compose-samples +level: BEGINNER +apiRefs: + - android:androidx.compose.Composable +license: apache2 diff --git a/Jetchat/ASSETS_LICENSE b/Jetchat/ASSETS_LICENSE new file mode 100644 index 0000000000..e7fc95866c --- /dev/null +++ b/Jetchat/ASSETS_LICENSE @@ -0,0 +1,88 @@ +All font files are licensed under the SIL OPEN FONT LICENSE license. All other files are licensed under the Apache 2 license. + + +SIL OPEN FONT LICENSE +Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/Jetchat/README.md b/Jetchat/README.md new file mode 100644 index 0000000000..0433a4585d --- /dev/null +++ b/Jetchat/README.md @@ -0,0 +1,104 @@ + + +# Jetchat sample + +Jetchat is a sample chat 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). + +This sample showcases: + +* UI state management +* Integration with Architecture Components: Navigation, Fragments, ViewModel +* Back button handling +* Text Input and focus management +* Multiple types of animations and transitions +* Saved state across configuration changes +* Material Design 3 theming and Material You dynamic color +* UI tests + +## Screenshots + + + + + + + +### Status: 🚧 In progress + +Jetchat is still in under development, and some features are not yet implemented. + +## Features + +### UI State management +The [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) composable is the entry point to this screen and takes a [ConversationUiState](app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt) that defines the data to be displayed. This doesn't mean all the state is served from a single point: composables can have their own state too. For an example, see `scrollState` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt) or `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) + +### Architecture Components +The [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt) shows how to pass data between fragments with the [Navigation component](https://developer.android.com/guide/navigation) and observe state from a +[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. 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 [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) + +### 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.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). + +### 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: + +#### [ConversationTest](app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt) +UI tests for the Conversation screen. Includes a test that checks the behavior of the app when dark mode changes. + +#### [NavigationTest](app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt) +Shows how to write tests that assert directly on the [Navigation Controller](https://developer.android.com/reference/androidx/navigation/NavController). + +#### [UserInputTest](app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt) +Checks that the user input composable, including extended controls, behave as expected showing and hiding the keyboard. + + +## Known issues +1. If the emoji selector is shown, clicking on the TextField can sometimes show both input methods. +Tracked in https://issuetracker.google.com/164859446 + +2. There are only two profiles, clicking on anybody except "me" will show the same data. + +## 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 diff --git a/Jetchat/app/.gitignore b/Jetchat/app/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetchat/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetchat/app/build.gradle.kts b/Jetchat/app/build.gradle.kts new file mode 100644 index 0000000000..28b482d11a --- /dev/null +++ b/Jetchat/app/build.gradle.kts @@ -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. + */ + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + 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") + } + } + + kotlinOptions { + jvmTarget = "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.material.iconsExtended) + 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 new file mode 100644 index 0000000000..9e6e059b3b --- /dev/null +++ b/Jetchat/app/proguard-rules.pro @@ -0,0 +1,38 @@ +# 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 + + +-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 new file mode 100644 index 0000000000..3d6baeee74 --- /dev/null +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt @@ -0,0 +1,168 @@ +/* + * 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 androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.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.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.data.exampleUiState +import com.example.compose.jetchat.theme.JetchatTheme +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * Checks that the features in the Conversation screen work as expected. + */ +class ConversationTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val themeIsDark = MutableStateFlow(false) + + @Before + fun setUp() { + // Launch the conversation screen + composeTestRule.setContent { + JetchatTheme(isDarkTheme = themeIsDark.collectAsStateWithLifecycle(false).value) { + ConversationContent( + uiState = conversationTestUiState, + navigateToProfile = { }, + onNavIconPressed = { } + ) + } + } + } + + @Test + fun app_launches() { + // Check that the conversation screen is visible on launch + composeTestRule.onNodeWithTag(ConversationTestTag).assertIsDisplayed() + } + + @Test + fun userScrollsUp_jumpToBottomAppears() { + // Check list is snapped to bottom and swipe up + findJumpToBottom().assertDoesNotExist() + composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { + this.swipe( + start = this.center, + end = Offset(this.center.x, this.center.y + 500), + durationMillis = 200 + ) + } + // Check that the jump to bottom button is shown + findJumpToBottom().assertIsDisplayed() + } + + @Test + fun jumpToBottom_snapsToBottomAndDisappears() { + // When the scroll is not snapped to the bottom + composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { + this.swipe( + start = this.center, + end = Offset(this.center.x, this.center.y + 500), + durationMillis = 200 + ) + } + // Snap scroll to the bottom + findJumpToBottom().performClick() + + // Check that the button is hidden + findJumpToBottom().assertDoesNotExist() + } + + @Test + fun jumpToBottom_snapsToBottomAfterUserInteracted() { + // First swipe + 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 + ) + } + // Second, snap to bottom + findJumpToBottom().performClick() + + // Open Emoji selector + openEmojiSelector() + + // Assert that the list is still snapped to bottom + findJumpToBottom().assertDoesNotExist() + } + + @Test + fun changeTheme_scrollIsPersisted() { + // Swipe to show the jump to bottom button + composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { + this.swipe( + start = this.center, + end = Offset(this.center.x, this.center.y + 500), + durationMillis = 200 + ) + } + + // Check that the jump to bottom button is shown + findJumpToBottom().assertIsDisplayed() + + // Set theme to dark + themeIsDark.value = true + + // Check that the jump to bottom button is still shown + findJumpToBottom().assertIsDisplayed() + } + + 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() +} + +/** + * Make the list of messages longer so the test makes sense on tablets. + */ +private val conversationTestUiState = ConversationUiState( + initialMessages = (exampleUiState.messages.plus(exampleUiState.messages)), + channelName = "#composers", + 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 new file mode 100644 index 0000000000..63fb05b784 --- /dev/null +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt @@ -0,0 +1,108 @@ +/* + * 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 androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +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 androidx.navigation.NavController +import androidx.navigation.findNavController +import androidx.test.espresso.Espresso +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Checks that the navigation flows in the app are correct. + */ +class NavigationTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun app_launches() { + // Check app launches at the correct destination + assertEquals(getNavController().currentDestination?.id, R.id.nav_home) + } + + @Test + fun profileScreen_back_conversationScreen() { + val navController = getNavController() + // Navigate to profile \ + navigateToProfile("Taylor Brooks") + // Check profile is displayed + assertEquals(navController.currentDestination?.id, R.id.nav_profile) + // Extra UI check + composeTestRule + .onNodeWithText(composeTestRule.activity.getString(R.string.display_name)) + .assertIsDisplayed() + + // Press back + Espresso.pressBack() + + // Check that we're home + 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 new file mode 100644 index 0000000000..cd601860bb --- /dev/null +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt @@ -0,0 +1,167 @@ +/* + * 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 androidx.activity.ComponentActivity +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +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.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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.data.exampleUiState +import com.example.compose.jetchat.theme.JetchatTheme +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test + +/** + * Checks that the user input composable, including extended controls, behave as expected. + */ +class UserInputTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val activity by lazy { composeTestRule.activity } + + @Before + fun setUp() { + // Launch the conversation screen + composeTestRule.setContent { + JetchatTheme { + ConversationContent( + uiState = exampleUiState, + navigateToProfile = { }, + onNavIconPressed = { } + ) + } + } + } + + @Test + @Ignore("Issue with keyboard sync https://issuetracker.google.com/169235317") + fun emojiSelector_isClosedWithBack() { + // Open emoji selector + openEmojiSelector() + // Check emoji selector is displayed + assertEmojiSelectorIsDisplayed() + + composeTestRule.onNode(SemanticsMatcher.expectValue(KeyboardShownKey, false)) + .assertExists() + // Press back button + Espresso.pressBack() + + // TODO: Workaround for synchronization issue with "back" + // https://issuetracker.google.com/169235317 + composeTestRule.waitUntil(timeoutMillis = 10_000) { + composeTestRule + .onAllNodesWithContentDescription(activity.getString(R.string.emoji_selector_desc)) + .fetchSemanticsNodes().isEmpty() + } + + // Check the emoji selector is not displayed + assertEmojiSelectorDoesNotExist() + } + + @Test + fun extendedUserInputShown_textFieldClicked_extendedUserInputHides() { + openEmojiSelector() + + // Click on text field + clickOnTextField() + + // Check the emoji selector is not displayed + assertEmojiSelectorDoesNotExist() + } + + @Test + fun keyboardShown_emojiSelectorOpened_keyboardHides() { + // Click on text field to open the soft keyboard + clickOnTextField() + + // TODO: Soft keyboard is not correctly synchronized + // https://issuetracker.google.com/169235317 + Thread.sleep(200) + + composeTestRule.onNode(SemanticsMatcher.expectValue(KeyboardShownKey, true)).assertExists() + + // When the emoji selector is extended + openEmojiSelector() + + // Check that the keyboard is hidden + composeTestRule.onNode(SemanticsMatcher.expectValue(KeyboardShownKey, false)).assertExists() + } + + @Test + @Ignore("Flaky due to https://issuetracker.google.com/169235317") + fun sendButton_enableToggles() { + // Given an initial state where there's no text in the textfield, + // check that the send button is disabled. + findSendButton().assertIsNotEnabled() + + // Add some text to the input field + findTextInputField().performTextInput("Some text") + + // The send button should be enabled + findSendButton().assertIsEnabled() + } + + private fun clickOnTextField() = + composeTestRule + .onNodeWithContentDescription(activity.getString(R.string.textfield_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 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))) + ) + } +} diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/Utils.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/Utils.kt new file mode 100644 index 0000000000..9ae5ac39d1 --- /dev/null +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/Utils.kt @@ -0,0 +1,28 @@ +/* + * 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 androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.printToLog + +/** + * Used to debug the semantic tree. + */ +fun ComposeTestRule.dumpSemanticNodes() { + this.onRoot().printToLog(tag = "JetchatLog") +} diff --git a/Jetchat/app/src/main/AndroidManifest.xml b/Jetchat/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b4ea01d944 --- /dev/null +++ b/Jetchat/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..51bb6d040a --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt @@ -0,0 +1,38 @@ +/* + * 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 androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Used to communicate between screens. + */ +class MainViewModel : ViewModel() { + + 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 new file mode 100644 index 0000000000..f395a57356 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt @@ -0,0 +1,117 @@ +/* + * 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.os.Bundle +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +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.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.ViewCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import com.example.compose.jetchat.components.JetchatDrawer +import com.example.compose.jetchat.databinding.ContentMainBinding +import kotlinx.coroutines.launch + +/** + * Main activity for the app. + */ +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 } + + setContentView( + ComposeView(this).apply { + consumeWindowInsets = false + setContent { + val drawerState = rememberDrawerState(initialValue = Closed) + val drawerOpen by viewModel.drawerShouldBeOpened + .collectAsStateWithLifecycle() + + var selectedMenu by remember { mutableStateOf("composers") } + if (drawerOpen) { + // Open drawer and reset state in VM. + LaunchedEffect(Unit) { + // wrap in try-finally to handle interruption whiles opening drawer + try { + drawerState.open() + } finally { + viewModel.resetOpenDrawerAction() + } + } + } + + val scope = rememberCoroutineScope() + + JetchatDrawer( + drawerState = drawerState, + selectedMenu = selectedMenu, + onChatClicked = { + findNavController().popBackStack(R.id.nav_home, false) + scope.launch { + drawerState.close() + } + selectedMenu = it + }, + onProfileClicked = { + val bundle = bundleOf("userId" to it) + findNavController().navigate(R.id.nav_profile, bundle) + scope.launch { + drawerState.close() + } + selectedMenu = it + } + ) { + AndroidViewBinding(ContentMainBinding::inflate) + } + } + } + ) + } + + override fun onSupportNavigateUp(): Boolean { + return findNavController().navigateUp() || super.onSupportNavigateUp() + } + + /** + * See https://issuetracker.google.com/142847973 + */ + private fun findNavController(): NavController { + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + return navHostFragment.navController + } +} 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 new file mode 100644 index 0000000000..a374c4107b --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/UiExtras.kt @@ -0,0 +1,41 @@ +/* + * 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 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 +fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + text = { + Text( + text = "Functionality not available \uD83D\uDE48", + 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 new file mode 100644 index 0000000000..d4617b6f85 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt @@ -0,0 +1,154 @@ +/* + * 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.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.Box +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 + +/** + * A layout that shows an icon and a text element used as the content for a FAB that extends with + * an animation. + */ +@Composable +fun AnimatingFabContent( + icon: @Composable () -> Unit, + text: @Composable () -> Unit, + modifier: Modifier = Modifier, + extended: Boolean = true +) { + val currentState = if (extended) ExpandableFabStates.Extended else ExpandableFabStates.Collapsed + 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 + ) + } else { + tween( + easing = LinearEasing, + delayMillis = (transitionDuration / 3f).roundToInt(), // 4 / 12 frames + durationMillis = (transitionDuration / 12f * 5).roundToInt() // 5 / 12 frames + ) + } + }, + label = "fab_text_opacity" + ) { state -> + if (state == ExpandableFabStates.Collapsed) { + 0f + } else { + 1f + } + } + val fabWidthFactor by transition.animateFloat( + transitionSpec = { + if (targetState == ExpandableFabStates.Collapsed) { + tween( + easing = FastOutSlowInEasing, + durationMillis = transitionDuration + ) + } else { + tween( + easing = FastOutSlowInEasing, + durationMillis = transitionDuration + ) + } + }, + label = "fab_width_factor" + ) { state -> + if (state == ExpandableFabStates.Collapsed) { + 0f + } else { + 1f + } + } + // Deferring reads using lambdas instead of Floats here can improve performance, + // preventing recompositions. + IconAndTextRow( + icon, + text, + { textOpacity }, + { fabWidthFactor }, + modifier = modifier + ) +} + +@Composable +private fun IconAndTextRow( + icon: @Composable () -> Unit, + text: @Composable () -> Unit, + opacityProgress: () -> Float, // Lambdas instead of Floats, to defer read + widthProgress: () -> Float, + modifier: Modifier +) { + Layout( + modifier = modifier, + content = { + icon() + Box(modifier = Modifier.graphicsLayer { alpha = opacityProgress() }) { + text() + } + } + ) { measurables, constraints -> + + val iconPlaceable = measurables[0].measure(constraints) + val textPlaceable = measurables[1].measure(constraints) + + val height = constraints.maxHeight + + // FAB has an aspect ratio of 1 so the initial width is the height + val initialWidth = height.toFloat() + + // Use it to get the padding + val iconPadding = (initialWidth - iconPlaceable.width) / 2f + + // The full width will be : padding + icon + padding + text + padding + val expandedWidth = iconPlaceable.width + textPlaceable.width + iconPadding * 3 + + // Apply the animation factor to go from initialWidth to fullWidth + val width = lerp(initialWidth, expandedWidth, widthProgress()) + + layout(width.roundToInt(), height) { + iconPlaceable.place( + iconPadding.roundToInt(), + constraints.maxHeight / 2 - iconPlaceable.height / 2 + ) + textPlaceable.place( + (iconPlaceable.width + iconPadding * 2).roundToInt(), + constraints.maxHeight / 2 - textPlaceable.height / 2 + ) + } + } +} + +private enum class ExpandableFabStates { Collapsed, Extended } + +private const val transitionDuration = 200 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 new file mode 100644 index 0000000000..c0f731aa0d --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/BaseLineHeightModifier.kt @@ -0,0 +1,64 @@ +/* + * 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.components + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp + +/** + * Applied to a Text, it sets the distance between the top and the first baseline. It + * also makes the bottom of the element coincide with the last baseline of the text. + * + * _______________ + * | | ↑ + * | | | heightFromBaseline + * |Hello, World!| ↓ + * ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + * + * This modifier can be used to distribute multiple text elements using a certain distance between + * baselines. + */ +data class BaselineHeightModifier( + val heightFromBaseline: Dp +) : LayoutModifier { + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints + ): MeasureResult { + + val textPlaceable = measurable.measure(constraints) + val firstBaseline = textPlaceable[FirstBaseline] + val lastBaseline = textPlaceable[LastBaseline] + + val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline + return layout(constraints.maxWidth, height) { + val topY = heightFromBaseline.roundToPx() - firstBaseline + textPlaceable.place(0, topY) + } + } +} + +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 new file mode 100644 index 0000000000..9d221bd6fa --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.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. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.example.compose.jetchat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +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.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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JetchatAppBar( + modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = null, + onNavIconPressed: () -> Unit = { }, + title: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit = {} +) { + 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() { + JetchatTheme { + JetchatAppBar(title = { Text("Preview!") }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +fun JetchatAppBarPreviewDark() { + JetchatTheme(isDarkTheme = true) { + JetchatAppBar(title = { Text("Preview!") }) + } +} 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 new file mode 100644 index 0000000000..dea971f692 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt @@ -0,0 +1,290 @@ +/* + * 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.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.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.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.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.example.compose.jetchat.widget.WidgetReceiver + +@Composable +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) + 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) { + JetchatIcon( + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Image( + painter = painterResource(id = R.drawable.jetchat_logo), + contentDescription = null, + modifier = Modifier.padding(start = 8.dp) + ) + } +} + +@Composable +private fun DrawerItemHeader(text: String) { + 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.colorScheme.primaryContainer) + } else { + Modifier + } + Row( + modifier = Modifier + .height(56.dp) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(CircleShape) + .then(background) + .clickable(onClick = onChatClicked), + verticalAlignment = CenterVertically + ) { + val iconTint = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Icon( + painter = painterResource(id = R.drawable.ic_jetchat), + tint = iconTint, + 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) + ) + } +} + +@Composable +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(56.dp) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(CircleShape) + .then(background) + .clickable(onClick = onProfileClicked), + verticalAlignment = CenterVertically + ) { + 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 { + JetchatDrawerContent({}, {}) + } + } + } +} + +@Composable +@Preview +fun DrawerPreviewDark() { + JetchatTheme(isDarkTheme = true) { + Surface { + Column { + 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..fad1045539 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatIcon.kt @@ -0,0 +1,56 @@ +/* + * 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 new file mode 100644 index 0000000000..df78ec2616 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt @@ -0,0 +1,55 @@ +/* + * 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.components + +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 JetchatDrawer( + drawerState: DrawerState = rememberDrawerState(initialValue = Closed), + selectedMenu: String, + onProfileClicked: (String) -> Unit, + onChatClicked: (String) -> Unit, + content: @Composable () -> Unit, +) { + JetchatTheme { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + drawerState = drawerState, + drawerContainerColor = MaterialTheme.colorScheme.background, + drawerContentColor = MaterialTheme.colorScheme.onBackground, + ) { + JetchatDrawerContent( + onProfileClicked = onProfileClicked, + onChatClicked = onChatClicked, + selectedMenu = selectedMenu + ) + } + }, + content = content + ) + } +} 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 new file mode 100644 index 0000000000..283e7f8b7f --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -0,0 +1,579 @@ +/* + * 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(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.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 +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +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.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +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.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 +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +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 kotlinx.coroutines.launch + +/** + * Entry point for a conversation screen. + * + * @param uiState [ConversationUiState] that contains messages to display + * @param navigateToProfile User action when navigation to a profile is requested + * @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 = { } +) { + 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() + + 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 + } + + 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, + 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, + 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(horizontalAlignment = Alignment.CenterHorizontally) { + // Channel name + Text( + text = channelName, + style = MaterialTheme.typography.titleMedium + ) + // Number of members + Text( + text = stringResource(R.string.members, channelMembers), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + actions = { + // Search icon + Icon( + imageVector = Icons.Outlined.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( + imageVector = Icons.Outlined.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 +) { + val scope = rememberCoroutineScope() + Box(modifier = modifier) { + + val authorMe = stringResource(id = R.string.author_me) + LazyColumn( + reverseLayout = true, + state = scrollState, + modifier = Modifier + .testTag(ConversationTestTag) + .fillMaxSize() + ) { + for (index in messages.indices) { + val prevAuthor = messages.getOrNull(index - 1)?.author + val nextAuthor = messages.getOrNull(index + 1)?.author + val content = messages[index] + val isFirstMessageByAuthor = prevAuthor != content.author + val isLastMessageByAuthor = nextAuthor != content.author + + // Hardcode day dividers for simplicity + if (index == messages.size - 1) { + item { + DayHeader("20 Aug") + } + } else if (index == 2) { + item { + DayHeader("Today") + } + } + + item { + Message( + onAuthorClick = { name -> navigateToProfile(name) }, + msg = content, + isUserMe = content.author == authorMe, + isFirstMessageByAuthor = isFirstMessageByAuthor, + isLastMessageByAuthor = isLastMessageByAuthor + ) + } + } + } + // Jump to bottom button shows up when user scrolls past a threshold. + // Convert to pixels: + val jumpThreshold = with(LocalDensity.current) { + JumpToBottomThreshold.toPx() + } + + // Show the button if the first visible item is not the first one or if the offset is + // greater than the threshold. + val jumpToBottomButtonEnabled by remember { + derivedStateOf { + scrollState.firstVisibleItemIndex != 0 || + scrollState.firstVisibleItemScrollOffset > jumpThreshold + } + } + + JumpToBottom( + // Only show if the scroller is not at the bottom + enabled = jumpToBottomButtonEnabled, + onClicked = { + scope.launch { + scrollState.animateScrollToItem(0) + } + }, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } +} + +@Composable +fun Message( + onAuthorClick: (String) -> Unit, + msg: Message, + isUserMe: Boolean, + isFirstMessageByAuthor: Boolean, + isLastMessageByAuthor: Boolean +) { + val borderColor = if (isUserMe) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.tertiary + } + + val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier + Row(modifier = spaceBetweenAuthors) { + if (isLastMessageByAuthor) { + // Avatar + Image( + modifier = Modifier + .clickable(onClick = { onAuthorClick(msg.author) }) + .padding(horizontal = 16.dp) + .size(42.dp) + .border(1.5.dp, borderColor, CircleShape) + .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) + .clip(CircleShape) + .align(Alignment.Top), + painter = painterResource(id = msg.authorImage), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + // Space under avatar + Spacer(modifier = Modifier.width(74.dp)) + } + AuthorAndTextMessage( + msg = msg, + isUserMe = isUserMe, + isFirstMessageByAuthor = isFirstMessageByAuthor, + isLastMessageByAuthor = isLastMessageByAuthor, + authorClicked = onAuthorClick, + modifier = Modifier + .padding(end = 16.dp) + .weight(1f) + ) + } +} + +@Composable +fun AuthorAndTextMessage( + msg: Message, + isUserMe: Boolean, + isFirstMessageByAuthor: Boolean, + isLastMessageByAuthor: Boolean, + authorClicked: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + if (isLastMessageByAuthor) { + AuthorNameTimestamp(msg) + } + ChatItemBubble(msg, isUserMe, authorClicked = authorClicked) + if (isFirstMessageByAuthor) { + // Last bubble before next author + Spacer(modifier = Modifier.height(8.dp)) + } else { + // Between bubbles + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Composable +private fun AuthorNameTimestamp(msg: Message) { + // Combine author and timestamp for a11y. + Row(modifier = Modifier.semantics(mergeDescendants = true) {}) { + Text( + text = msg.author, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .alignBy(LastBaseline) + .paddingFrom(LastBaseline, after = 8.dp) // Space to 1st bubble + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = msg.timestamp, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alignBy(LastBaseline), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +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) + ) { + DayHeaderLine() + Text( + text = dayString, + modifier = Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + DayHeaderLine() + } +} + +@Composable +private fun RowScope.DayHeaderLine() { + Divider( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) +} + +@Composable +fun ChatItemBubble( + message: Message, + isUserMe: Boolean, + authorClicked: (String) -> Unit +) { + + val backgroundBubbleColor = if (isUserMe) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + + Column { + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape + ) { + ClickableMessage( + message = message, + isUserMe = isUserMe, + authorClicked = authorClicked + ) + } + + message.image?.let { + Spacer(modifier = Modifier.height(4.dp)) + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape + ) { + Image( + painter = painterResource(it), + contentScale = ContentScale.Fit, + modifier = Modifier.size(160.dp), + contentDescription = stringResource(id = R.string.attached_image) + ) + } + } + } +} + +@Composable +fun ClickableMessage( + message: Message, + isUserMe: Boolean, + authorClicked: (String) -> Unit +) { + val uriHandler = LocalUriHandler.current + + val styledMessage = messageFormatter( + text = message.content, + primary = isUserMe + ) + + ClickableText( + text = styledMessage, + style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), + modifier = Modifier.padding(16.dp), + onClick = { + styledMessage + .getStringAnnotations(start = it, end = it) + .firstOrNull() + ?.let { annotation -> + when (annotation.tag) { + SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item) + SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item) + else -> Unit + } + } + } + ) +} + +@Preview +@Composable +fun ConversationPreview() { + JetchatTheme { + ConversationContent( + uiState = exampleUiState, + navigateToProfile = { } + ) + } +} + +@Preview +@Composable +fun ChannelBarPrev() { + JetchatTheme { + ChannelNameBar(channelName = "composers", channelMembers = 52) + } +} + +@Preview +@Composable +fun DayHeaderPrev() { + DayHeader("Aug 6") +} + +private val JumpToBottomThreshold = 56.dp 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 new file mode 100644 index 0000000000..efdc12401f --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt @@ -0,0 +1,65 @@ +/* + * 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 android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.compose.ui.platform.ComposeView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +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 + +class ConversationFragment : Fragment() { + + private val activityViewModel: MainViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(inflater.context).apply { + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) + + setContent { + JetchatTheme { + ConversationContent( + uiState = exampleUiState, + navigateToProfile = { user -> + // Click callback + val bundle = bundleOf("userId" to user) + findNavController().navigate( + R.id.nav_profile, + bundle + ) + }, + onNavIconPressed = { + activityViewModel.openDrawer() + } + ) + } + } + } +} 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 new file mode 100644 index 0000000000..edd61c738d --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt @@ -0,0 +1,43 @@ +/* + * 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.compose.runtime.Immutable +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 = initialMessages.toMutableStateList() + val messages: List = _messages + + fun addMessage(msg: Message) { + _messages.add(0, msg) // Add to the beginning of the list + } +} + +@Immutable +data class Message( + val author: String, + val content: String, + val timestamp: String, + 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 new file mode 100644 index 0000000000..dfe517e03e --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt @@ -0,0 +1,89 @@ +/* + * 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.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.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.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.compose.jetchat.R + +private enum class Visibility { + VISIBLE, + GONE +} + +/** + * Shows a button that lets the user scroll to the bottom. + */ +@Composable +fun JumpToBottom( + enabled: Boolean, + onClicked: () -> Unit, + modifier: Modifier = Modifier +) { + // Show Jump to Bottom button + 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 { + 32.dp + } + } + if (bottomOffset > 0.dp) { + ExtendedFloatingActionButton( + icon = { + Icon( + imageVector = Icons.Filled.ArrowDownward, + modifier = Modifier.height(18.dp), + contentDescription = null + ) + }, + text = { + Text(text = stringResource(id = R.string.jumpBottom)) + }, + onClick = onClicked, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + modifier = modifier + .offset(x = 0.dp, y = -bottomOffset) + .height(36.dp) + ) + } +} + +@Preview +@Composable +fun JumpToBottomPreview() { + JumpToBottom(enabled = true, onClicked = {}) +} 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 new file mode 100644 index 0000000000..46cf46b301 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt @@ -0,0 +1,180 @@ +/* + * 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.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 +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.sp + +// Regex containing the syntax tokens +val symbolPattern by lazy { + Regex("""(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)""") +} + +// Accepted annotations for the ClickableTextWrapper +enum class SymbolAnnotationType { + PERSON, LINK +} +typealias StringAnnotation = AnnotatedString.Range +// Pair returning styled content and annotation for ClickableText when matching syntax token +typealias SymbolAnnotation = Pair + +/** + * Format a message following Markdown-lite syntax + * | @username -> bold, primary color and clickable element + * | http(s)://... -> clickable link, opening it into the browser + * | *bold* -> bold + * | _italic_ -> italic + * | ~strikethrough~ -> strikethrough + * | `MyClass.myMethod` -> inline code styling + * + * @param text contains message to be parsed + * @return AnnotatedString with annotations used inside the ClickableText wrapper + */ +@Composable +fun messageFormatter( + text: String, + primary: Boolean +): AnnotatedString { + val tokens = symbolPattern.findAll(text) + + return buildAnnotatedString { + + var cursorPosition = 0 + + val codeSnippetBackground = + if (primary) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.surface + } + + for (token in tokens) { + append(text.slice(cursorPosition until token.range.first)) + + val (annotatedString, stringAnnotation) = getSymbolAnnotation( + matchResult = token, + colorScheme = MaterialTheme.colorScheme, + primary = primary, + codeSnippetBackground = codeSnippetBackground + ) + append(annotatedString) + + if (stringAnnotation != null) { + val (item, start, end, tag) = stringAnnotation + addStringAnnotation(tag = tag, start = start, end = end, annotation = item) + } + + cursorPosition = token.range.last + 1 + } + + if (!tokens.none()) { + append(text.slice(cursorPosition..text.lastIndex)) + } else { + append(text) + } + } +} + +/** + * Map regex matches found in a message with supported syntax symbols + * + * @param matchResult is a regex result matching our syntax symbols + * @return pair of AnnotatedString with annotation (optional) used inside the ClickableText wrapper + */ +private fun getSymbolAnnotation( + matchResult: MatchResult, + colorScheme: ColorScheme, + primary: Boolean, + codeSnippetBackground: Color +): SymbolAnnotation { + return when (matchResult.value.first()) { + '@' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value, + spanStyle = SpanStyle( + 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 + ) + ) + '*' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('*'), + spanStyle = SpanStyle(fontWeight = FontWeight.Bold) + ), + null + ) + '_' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('_'), + spanStyle = SpanStyle(fontStyle = FontStyle.Italic) + ), + null + ) + '~' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('~'), + spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough) + ), + null + ) + '`' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value.trim('`'), + spanStyle = SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + background = codeSnippetBackground, + baselineShift = BaselineShift(0.2f) + ) + ), + null + ) + 'h' -> SymbolAnnotation( + AnnotatedString( + text = matchResult.value, + spanStyle = SpanStyle( + color = if (primary) colorScheme.inversePrimary else colorScheme.primary + ) + ), + StringAnnotation( + item = matchResult.value, + start = matchResult.range.first, + end = matchResult.range.last, + 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..5c2b7d3167 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/RecordButton.kt @@ -0,0 +1,186 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +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.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.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.rememberRichTooltipPositionProvider(), + tooltip = { + RichTooltip { + Text(stringResource(R.string.touch_and_hold_to_record)) + } + }, + enableUserInput = false, + state = tooltipState + ) { + Icon( + Icons.Default.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 new file mode 100644 index 0000000000..cb28308976 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -0,0 +1,816 @@ +/* + * 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.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 +import androidx.compose.foundation.layout.paddingFrom +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.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.LaunchedEffect +import androidx.compose.runtime.SideEffect +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.clipToBounds +import androidx.compose.ui.focus.FocusRequester +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.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +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.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +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.compose.jetchat.FunctionalityNotAvailablePopup +import com.example.compose.jetchat.R +import kotlin.math.absoluteValue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay + +enum class InputSelector { + NONE, + MAP, + DM, + EMOJI, + PHONE, + PICTURE +} + +enum class EmojiStickerSelector { + EMOJI, + STICKER +} + +@Preview +@Composable +fun UserInputPreview() { + UserInput(onMessageSent = {}) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +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) { + BackHandler(onBack = dismissKeyboard) + } + + var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + + // Used to decide if the keyboard should be shown + var textFieldFocusState by remember { mutableStateOf(false) } + + 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() + }, + 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 + ) + } + } +} + +private fun TextFieldValue.addText(newString: String): TextFieldValue { + val newText = this.text.replaceRange( + this.selection.start, + this.selection.end, + newString + ) + val newSelection = TextRange( + start = newText.length, + end = newText.length + ) + + return this.copy(text = newText, selection = newSelection) +} + +@Composable +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() + // If the selector is shown, always request focus to trigger a TextField.onFocusChange. + SideEffect { + if (currentSelector == InputSelector.EMOJI) { + focusRequester.requestFocus() + } + } + + 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() + } + } + } +} + +@Composable +fun FunctionalityNotAvailablePanel() { + AnimatedVisibility( + visibleState = remember { MutableTransitionState(false).apply { targetState = true } }, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut() + ) { + Column( + modifier = Modifier + .height(320.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.not_available), + 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 + ) + } + } +} + +@Composable +private fun UserInputSelector( + onSelectorChange: (InputSelector) -> Unit, + sendMessageEnabled: Boolean, + onMessageSent: () -> Unit, + currentInputSelector: InputSelector, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .height(72.dp) + .wrapContentHeight() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.EMOJI) }, + icon = Icons.Outlined.Mood, + selected = currentInputSelector == InputSelector.EMOJI, + description = stringResource(id = R.string.emoji_selector_bt_desc) + ) + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.DM) }, + icon = Icons.Outlined.AlternateEmail, + selected = currentInputSelector == InputSelector.DM, + description = stringResource(id = R.string.dm_desc) + ) + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.PICTURE) }, + icon = Icons.Outlined.InsertPhoto, + selected = currentInputSelector == InputSelector.PICTURE, + description = stringResource(id = R.string.attach_photo_desc) + ) + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.MAP) }, + icon = Icons.Outlined.Place, + selected = currentInputSelector == InputSelector.MAP, + description = stringResource(id = R.string.map_selector_desc) + ) + InputSelectorButton( + onClick = { onSelectorChange(InputSelector.PHONE) }, + icon = Icons.Outlined.Duo, + selected = currentInputSelector == InputSelector.PHONE, + description = stringResource(id = R.string.videochat_desc) + ) + + val border = if (!sendMessageEnabled) { + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } else { + null + } + Spacer(modifier = Modifier.weight(1f)) + + val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + + val buttonColors = ButtonDefaults.buttonColors( + disabledContainerColor = Color.Transparent, + disabledContentColor = disabledContentColor + ) + + // Send button + Button( + modifier = Modifier.height(36.dp), + enabled = sendMessageEnabled, + onClick = onMessageSent, + colors = buttonColors, + border = border, + contentPadding = PaddingValues(0.dp) + ) { + Text( + stringResource(id = R.string.send), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } +} + +@Composable +private fun InputSelectorButton( + onClick: () -> Unit, + icon: ImageVector, + description: String, + selected: Boolean, + modifier: Modifier = Modifier +) { + 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 + ) + } +} + +@Composable +private fun NotAvailablePopup(onDismissed: () -> Unit) { + FunctionalityNotAvailablePopup(onDismissed) +} + +val KeyboardShownKey = SemanticsPropertyKey("KeyboardShownKey") +var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey + +@OptIn(ExperimentalAnimationApi::class) +@ExperimentalFoundationApi +@Composable +private fun UserInputText( + keyboardType: KeyboardType = KeyboardType.Text, + onTextChanged: (TextFieldValue) -> Unit, + textFieldValue: TextFieldValue, + keyboardShown: Boolean, + onTextFieldFocused: (Boolean) -> Unit, + 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(64.dp), + horizontalArrangement = Arrangement.End + ) { + 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 + } + ) + } + } + } + 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 +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) + Column( + modifier = Modifier + .focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed + // Make the emoji selector focusable so it can steal focus from TextField + .focusTarget() + .semantics { contentDescription = a11yLabel } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + ExtendedSelectorInnerButton( + text = stringResource(id = R.string.emojis_label), + onClick = { selected = EmojiStickerSelector.EMOJI }, + selected = true, + modifier = Modifier.weight(1f) + ) + ExtendedSelectorInnerButton( + text = stringResource(id = R.string.stickers_label), + onClick = { selected = EmojiStickerSelector.STICKER }, + selected = false, + modifier = Modifier.weight(1f) + ) + } + Row(modifier = Modifier.verticalScroll(rememberScrollState())) { + EmojiTable(onTextAdded, modifier = Modifier.padding(8.dp)) + } + } + if (selected == EmojiStickerSelector.STICKER) { + NotAvailablePopup(onDismissed = { selected = EmojiStickerSelector.EMOJI }) + } +} + +@Composable +fun ExtendedSelectorInnerButton( + text: String, + onClick: () -> Unit, + selected: Boolean, + modifier: Modifier = Modifier +) { + val colors = ButtonDefaults.buttonColors( + 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(8.dp) + .height(36.dp), + colors = colors, + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall + ) + } +} + +@Composable +fun EmojiTable( + onTextAdded: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier.fillMaxWidth()) { + repeat(4) { x -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + repeat(EMOJI_COLUMNS) { y -> + val emoji = emojis[x * EMOJI_COLUMNS + y] + Text( + modifier = Modifier + .clickable(onClick = { onTextAdded(emoji) }) + .sizeIn(minWidth = 42.dp, minHeight = 42.dp) + .padding(8.dp), + text = emoji, + style = LocalTextStyle.current.copy( + fontSize = 18.sp, + textAlign = TextAlign.Center + ) + ) + } + } + } + } +} + +private const val EMOJI_COLUMNS = 10 + +private val emojis = listOf( + "\ud83d\ude00", // Grinning Face + "\ud83d\ude01", // Grinning Face With Smiling Eyes + "\ud83d\ude02", // Face With Tears of Joy + "\ud83d\ude03", // Smiling Face With Open Mouth + "\ud83d\ude04", // Smiling Face With Open Mouth and Smiling Eyes + "\ud83d\ude05", // Smiling Face With Open Mouth and Cold Sweat + "\ud83d\ude06", // Smiling Face With Open Mouth and Tightly-Closed Eyes + "\ud83d\ude09", // Winking Face + "\ud83d\ude0a", // Smiling Face With Smiling Eyes + "\ud83d\ude0b", // Face Savouring Delicious Food + "\ud83d\ude0e", // Smiling Face With Sunglasses + "\ud83d\ude0d", // Smiling Face With Heart-Shaped Eyes + "\ud83d\ude18", // Face Throwing a Kiss + "\ud83d\ude17", // Kissing Face + "\ud83d\ude19", // Kissing Face With Smiling Eyes + "\ud83d\ude1a", // Kissing Face With Closed Eyes + "\u263a", // White Smiling Face + "\ud83d\ude42", // Slightly Smiling Face + "\ud83e\udd17", // Hugging Face + "\ud83d\ude07", // Smiling Face With Halo + "\ud83e\udd13", // Nerd Face + "\ud83e\udd14", // Thinking Face + "\ud83d\ude10", // Neutral Face + "\ud83d\ude11", // Expressionless Face + "\ud83d\ude36", // Face Without Mouth + "\ud83d\ude44", // Face With Rolling Eyes + "\ud83d\ude0f", // Smirking Face + "\ud83d\ude23", // Persevering Face + "\ud83d\ude25", // Disappointed but Relieved Face + "\ud83d\ude2e", // Face With Open Mouth + "\ud83e\udd10", // Zipper-Mouth Face + "\ud83d\ude2f", // Hushed Face + "\ud83d\ude2a", // Sleepy Face + "\ud83d\ude2b", // Tired Face + "\ud83d\ude34", // Sleeping Face + "\ud83d\ude0c", // Relieved Face + "\ud83d\ude1b", // Face With Stuck-Out Tongue + "\ud83d\ude1c", // Face With Stuck-Out Tongue and Winking Eye + "\ud83d\ude1d", // Face With Stuck-Out Tongue and Tightly-Closed Eyes + "\ud83d\ude12", // Unamused Face + "\ud83d\ude13", // Face With Cold Sweat + "\ud83d\ude14", // Pensive Face + "\ud83d\ude15", // Confused Face + "\ud83d\ude43", // Upside-Down Face + "\ud83e\udd11", // Money-Mouth Face + "\ud83d\ude32", // Astonished Face + "\ud83d\ude37", // Face With Medical Mask + "\ud83e\udd12", // Face With Thermometer + "\ud83e\udd15", // Face With Head-Bandage + "\u2639", // White Frowning Face + "\ud83d\ude41", // Slightly Frowning Face + "\ud83d\ude16", // Confounded Face + "\ud83d\ude1e", // Disappointed Face + "\ud83d\ude1f", // Worried Face + "\ud83d\ude24", // Face With Look of Triumph + "\ud83d\ude22", // Crying Face + "\ud83d\ude2d", // Loudly Crying Face + "\ud83d\ude26", // Frowning Face With Open Mouth + "\ud83d\ude27", // Anguished Face + "\ud83d\ude28", // Fearful Face + "\ud83d\ude29", // Weary Face + "\ud83d\ude2c", // Grimacing Face + "\ud83d\ude30", // Face With Open Mouth and Cold Sweat + "\ud83d\ude31", // Face Screaming in Fear + "\ud83d\ude33", // Flushed Face + "\ud83d\ude35", // Dizzy Face + "\ud83d\ude21", // Pouting Face + "\ud83d\ude20", // Angry Face + "\ud83d\ude08", // Smiling Face With Horns + "\ud83d\udc7f", // Imp + "\ud83d\udc79", // Japanese Ogre + "\ud83d\udc7a", // Japanese Goblin + "\ud83d\udc80", // Skull + "\ud83d\udc7b", // Ghost + "\ud83d\udc7d", // Extraterrestrial Alien + "\ud83e\udd16", // Robot Face + "\ud83d\udca9", // Pile of Poo + "\ud83d\ude3a", // Smiling Cat Face With Open Mouth + "\ud83d\ude38", // Grinning Cat Face With Smiling Eyes + "\ud83d\ude39", // Cat Face With Tears of Joy + "\ud83d\ude3b", // Smiling Cat Face With Heart-Shaped Eyes + "\ud83d\ude3c", // Cat Face With Wry Smile + "\ud83d\ude3d", // Kissing Cat Face With Closed Eyes + "\ud83d\ude40", // Weary Cat Face + "\ud83d\ude3f", // Crying Cat Face + "\ud83d\ude3e", // Pouting Cat Face + "\ud83d\udc66", // Boy + "\ud83d\udc67", // Girl + "\ud83d\udc68", // Man + "\ud83d\udc69", // Woman + "\ud83d\udc74", // Older Man + "\ud83d\udc75", // Older Woman + "\ud83d\udc76", // Baby + "\ud83d\udc71", // Person With Blond Hair + "\ud83d\udc6e", // Police Officer + "\ud83d\udc72", // Man With Gua Pi Mao + "\ud83d\udc73", // Man With Turban + "\ud83d\udc77", // Construction Worker + "\u26d1", // Helmet With White Cross + "\ud83d\udc78", // Princess + "\ud83d\udc82", // Guardsman + "\ud83d\udd75", // Sleuth or Spy + "\ud83c\udf85", // Father Christmas + "\ud83d\udc70", // Bride With Veil + "\ud83d\udc7c", // Baby Angel + "\ud83d\udc86", // Face Massage + "\ud83d\udc87", // Haircut + "\ud83d\ude4d", // Person Frowning + "\ud83d\ude4e", // Person With Pouting Face + "\ud83d\ude45", // Face With No Good Gesture + "\ud83d\ude46", // Face With OK Gesture + "\ud83d\udc81", // Information Desk Person + "\ud83d\ude4b", // Happy Person Raising One Hand + "\ud83d\ude47", // Person Bowing Deeply + "\ud83d\ude4c", // Person Raising Both Hands in Celebration + "\ud83d\ude4f", // Person With Folded Hands + "\ud83d\udde3", // Speaking Head in Silhouette + "\ud83d\udc64", // Bust in Silhouette + "\ud83d\udc65", // Busts in Silhouette + "\ud83d\udeb6", // Pedestrian + "\ud83c\udfc3", // Runner + "\ud83d\udc6f", // Woman With Bunny Ears + "\ud83d\udc83", // Dancer + "\ud83d\udd74", // Man in Business Suit Levitating + "\ud83d\udc6b", // Man and Woman Holding Hands + "\ud83d\udc6c", // Two Men Holding Hands + "\ud83d\udc6d", // Two Women Holding Hands + "\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 new file mode 100644 index 0000000000..102930dd64 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt @@ -0,0 +1,136 @@ +/* + * 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.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 + +val initialMessages = listOf( + Message( + "me", + "Check it out!", + "8:07 PM" + ), + Message( + "me", + "Thank you!$EMOJI_PINK_HEART", + "8:06 PM", + R.drawable.sticker + ), + Message( + "Taylor Brooks", + "You can use all the same stuff", + "8:05 PM" + ), + Message( + "Taylor Brooks", + "@aliconors Take a look at the `Flow.collectAsStateWithLifecycle()` APIs", + "8:05 PM" + ), + Message( + "John Glenn", + "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 $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 +) + +/** + * Example colleague profile + */ +val colleagueProfile = ProfileScreenState( + userId = "12345", + photo = R.drawable.someone_else, + name = "Taylor Brooks", + status = "Away", + displayName = "taylor", + position = "Senior Android Dev at Openlane", + twitter = "twitter.com/taylorbrookscodes", + timeZone = "12:25 AM local time (Eastern Daylight Time)", + commonChannels = "2" +) + +/** + * Example "me" profile. + */ +val meProfile = ProfileScreenState( + userId = "me", + photo = R.drawable.ali, + name = "Ali Conors", + status = "Online", + displayName = "aliconors", + position = "Senior Android Dev at Yearin\nGoogle Developer Expert", + twitter = "twitter.com/aliconors", + timeZone = "In your timezone", + 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/Previews.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Previews.kt new file mode 100644 index 0000000000..337abb5a0f --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Previews.kt @@ -0,0 +1,70 @@ +/* + * 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.profile + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.example.compose.jetchat.data.colleagueProfile +import com.example.compose.jetchat.data.meProfile +import com.example.compose.jetchat.theme.JetchatTheme + +@Preview(widthDp = 340, name = "340 width - Me") +@Composable +fun ProfilePreview340() { + JetchatTheme { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 480, name = "480 width - Me") +@Composable +fun ProfilePreview480Me() { + JetchatTheme { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 480, name = "480 width - Other") +@Composable +fun ProfilePreview480Other() { + JetchatTheme { + ProfileScreen(colleagueProfile) + } +} +@Preview(widthDp = 340, name = "340 width - Me - Dark") +@Composable +fun ProfilePreview340MeDark() { + JetchatTheme(isDarkTheme = true) { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 480, name = "480 width - Me - Dark") +@Composable +fun ProfilePreview480MeDark() { + JetchatTheme(isDarkTheme = true) { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 480, name = "480 width - Other - Dark") +@Composable +fun ProfilePreview480OtherDark() { + JetchatTheme(isDarkTheme = true) { + ProfileScreen(colleagueProfile) + } +} 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 new file mode 100644 index 0000000000..0bc8c651ae --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt @@ -0,0 +1,307 @@ +/* + * 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.profile + +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.BoxWithConstraints +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.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.icons.Icons +import androidx.compose.material.icons.outlined.Chat +import androidx.compose.material.icons.outlined.Create +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +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.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.baselineHeight +import com.example.compose.jetchat.data.colleagueProfile +import com.example.compose.jetchat.data.meProfile +import com.example.compose.jetchat.theme.JetchatTheme + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun ProfileScreen( + userData: ProfileScreenState, + nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection() +) { + var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } + if (functionalityNotAvailablePopupShown) { + FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } + } + + val scrollState = rememberScrollState() + + 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) + } + } + + 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 } + ) + } +} + +@Composable +private fun UserInfoFields(userData: ProfileScreenState, containerHeight: Dp) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + + NameAndPosition(userData) + + ProfileProperty(stringResource(R.string.display_name), userData.displayName) + + ProfileProperty(stringResource(R.string.status), userData.status) + + ProfileProperty(stringResource(R.string.twitter), userData.twitter, isLink = true) + + userData.timeZone?.let { + ProfileProperty(stringResource(R.string.timezone), userData.timeZone) + } + + // Add a spacer that always shows part (320.dp) of the fields list regardless of the device, + // in order to always leave some content at the top. + Spacer(Modifier.height((containerHeight - 320.dp).coerceAtLeast(0.dp))) + } +} + +@Composable +private fun NameAndPosition( + userData: ProfileScreenState +) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Name( + userData, + modifier = Modifier.baselineHeight(32.dp) + ) + Position( + userData, + modifier = Modifier + .padding(bottom = 20.dp) + .baselineHeight(24.dp) + ) + } +} + +@Composable +private fun Name(userData: ProfileScreenState, modifier: Modifier = Modifier) { + Text( + text = userData.name, + modifier = modifier, + style = MaterialTheme.typography.headlineSmall + ) +} + +@Composable +private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier) { + Text( + text = userData.position, + modifier = modifier, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@Composable +private fun ProfileHeader( + scrollState: ScrollState, + data: ProfileScreenState, + containerHeight: Dp +) { + val offset = (scrollState.value / 2) + val offsetDp = with(LocalDensity.current) { offset.toDp() } + + data.photo?.let { + Image( + modifier = Modifier + .heightIn(max = containerHeight / 2) + .fillMaxWidth() + // 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 + ) + } +} + +@Composable +fun ProfileProperty(label: String, value: String, isLink: Boolean = false) { + Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) { + Divider() + Text( + text = label, + modifier = Modifier.baselineHeight(24.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + val style = if (isLink) { + MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary) + } else { + MaterialTheme.typography.bodyLarge + } + Text( + text = value, + modifier = Modifier.baselineHeight(24.dp), + style = style + ) + } +} + +@Composable +fun ProfileError() { + Text(stringResource(R.string.profile_error)) +} + +@Composable +fun ProfileFab( + extended: Boolean, + userIsMe: Boolean, + modifier: Modifier = Modifier, + onFabClicked: () -> Unit = { } +) { + key(userIsMe) { // Prevent multiple invocations to execute during composition + FloatingActionButton( + onClick = onFabClicked, + modifier = modifier + .padding(16.dp) + .navigationBarsPadding() + .height(48.dp) + .widthIn(min = 48.dp), + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) { + AnimatingFabContent( + icon = { + Icon( + imageVector = if (userIsMe) Icons.Outlined.Create else Icons.Outlined.Chat, + contentDescription = stringResource( + 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 + ), + ) + }, + extended = extended + ) + } + } +} + +@Preview(widthDp = 640, heightDp = 360) +@Composable +fun ConvPreviewLandscapeMeDefault() { + JetchatTheme { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 360, heightDp = 480) +@Composable +fun ConvPreviewPortraitMeDefault() { + JetchatTheme { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 360, heightDp = 480) +@Composable +fun ConvPreviewPortraitOtherDefault() { + JetchatTheme { + ProfileScreen(colleagueProfile) + } +} + +@Preview +@Composable +fun ProfileFabPreview() { + 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 new file mode 100644 index 0000000000..971a008772 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.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.compose.jetchat.profile + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +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.material.icons.Icons +import androidx.compose.material.icons.outlined.MoreVert +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.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 + +class ProfileFragment : Fragment() { + + private val viewModel: ProfileViewModel by viewModels() + private val activityViewModel: MainViewModel by activityViewModels() + + override fun onAttach(context: Context) { + super.onAttach(context) + // Consider using safe args plugin + val userId = arguments?.getString("userId") + viewModel.setUserId(userId) + } + + @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) + + 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( + imageVector = Icons.Outlined.MoreVert, + 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) + ) + } + ) + } + } + } + + rootView.findViewById(R.id.profile_compose_view).apply { + setContent { + val userData by viewModel.userData.observeAsState() + val nestedScrollInteropConnection = rememberNestedScrollInteropConnection() + + JetchatTheme { + if (userData == null) { + ProfileError() + } else { + ProfileScreen( + userData = userData!!, + 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 new file mode 100644 index 0000000000..bbb7b8d823 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt @@ -0,0 +1,60 @@ +/* + * 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.profile + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.example.compose.jetchat.data.colleagueProfile +import com.example.compose.jetchat.data.meProfile + +class ProfileViewModel : ViewModel() { + + private var userId: String = "" + + fun setUserId(newUserId: String?) { + if (newUserId != userId) { + userId = newUserId ?: meProfile.userId + } + // Workaround for simplicity + _userData.value = if (userId == meProfile.userId || userId == meProfile.displayName) { + meProfile + } else { + colleagueProfile + } + } + + private val _userData = MutableLiveData() + val userData: LiveData = _userData +} + +@Immutable +data class ProfileScreenState( + val userId: String, + @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 +) { + 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 new file mode 100644 index 0000000000..35d5181f7d --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt @@ -0,0 +1,60 @@ +/* + * 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.theme + +import androidx.compose.ui.graphics.Color + +val Blue10 = Color(0xFF000F5E) +val Blue20 = Color(0xFF001E92) +val Blue30 = Color(0xFF002ECC) +val Blue40 = Color(0xFF1546F6) +val Blue80 = Color(0xFFB8C3FF) +val Blue90 = Color(0xFFDDE1FF) + +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/Themes.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt new file mode 100644 index 0000000000..8fce79255d --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt @@ -0,0 +1,113 @@ +/* + * 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 android.annotation.SuppressLint +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +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 + +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 +) + +val JetchatLightColorScheme = lightColorScheme( + primary = Blue40, + onPrimary = 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(), + 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( + colorScheme = myColorScheme, + typography = JetchatTypography, + 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 new file mode 100644 index 0000000000..035d179988 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt @@ -0,0 +1,163 @@ +/* + * 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.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 + +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 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( + displayLarge = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Light, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = 0.sp + ), + displayMedium = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Light, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Normal, + 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 + ), + headlineMedium = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = KarlaFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + bodyLarge = TextStyle( + fontFamily = KarlaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + bodyMedium = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = KarlaFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + 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, + 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..0f6c4ea1a9 --- /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..99b1b4183e --- /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-nodpi/ali.png b/Jetchat/app/src/main/res/drawable-nodpi/ali.png new file mode 100644 index 0000000000..eab3c658a8 Binary files /dev/null and b/Jetchat/app/src/main/res/drawable-nodpi/ali.png differ diff --git a/Jetchat/app/src/main/res/drawable-nodpi/someone_else.jpg b/Jetchat/app/src/main/res/drawable-nodpi/someone_else.jpg new file mode 100644 index 0000000000..95bc4a8bbc Binary files /dev/null and b/Jetchat/app/src/main/res/drawable-nodpi/someone_else.jpg differ diff --git a/Jetchat/app/src/main/res/drawable-nodpi/sticker.png b/Jetchat/app/src/main/res/drawable-nodpi/sticker.png new file mode 100644 index 0000000000..54bbcfb96e Binary files /dev/null and b/Jetchat/app/src/main/res/drawable-nodpi/sticker.png differ diff --git a/Jetchat/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/Jetchat/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..61686d58a2 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..dbcd75673d --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_baseline_person_24.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/Jetchat/app/src/main/res/drawable/ic_jetchat.xml b/Jetchat/app/src/main/res/drawable/ic_jetchat.xml new file mode 100644 index 0000000000..402fc19ac3 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_jetchat.xml @@ -0,0 +1,42 @@ + + + + + + + + + + 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 new file mode 100644 index 0000000000..bdc8735ded --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + 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/jetchat_logo.xml b/Jetchat/app/src/main/res/drawable/jetchat_logo.xml new file mode 100644 index 0000000000..6a89c0b954 --- /dev/null +++ b/Jetchat/app/src/main/res/drawable/jetchat_logo.xml @@ -0,0 +1,32 @@ + + + + + + + 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/font/karla_bold.ttf b/Jetchat/app/src/main/res/font/karla_bold.ttf new file mode 100644 index 0000000000..052231c165 Binary files /dev/null and b/Jetchat/app/src/main/res/font/karla_bold.ttf differ diff --git a/Jetchat/app/src/main/res/font/karla_regular.ttf b/Jetchat/app/src/main/res/font/karla_regular.ttf new file mode 100644 index 0000000000..4269aa069e Binary files /dev/null and b/Jetchat/app/src/main/res/font/karla_regular.ttf differ diff --git a/Jetchat/app/src/main/res/font/montserrat_light.ttf b/Jetchat/app/src/main/res/font/montserrat_light.ttf new file mode 100644 index 0000000000..990857de8e Binary files /dev/null and b/Jetchat/app/src/main/res/font/montserrat_light.ttf differ diff --git a/Jetchat/app/src/main/res/font/montserrat_medium.ttf b/Jetchat/app/src/main/res/font/montserrat_medium.ttf new file mode 100755 index 0000000000..6e079f6984 Binary files /dev/null and b/Jetchat/app/src/main/res/font/montserrat_medium.ttf differ diff --git a/Jetchat/app/src/main/res/font/montserrat_regular.ttf b/Jetchat/app/src/main/res/font/montserrat_regular.ttf new file mode 100755 index 0000000000..8d443d5d56 Binary files /dev/null and b/Jetchat/app/src/main/res/font/montserrat_regular.ttf differ diff --git a/Jetchat/app/src/main/res/font/montserrat_semibold.ttf b/Jetchat/app/src/main/res/font/montserrat_semibold.ttf new file mode 100755 index 0000000000..f8a43f2b20 Binary files /dev/null and b/Jetchat/app/src/main/res/font/montserrat_semibold.ttf differ diff --git a/Jetchat/app/src/main/res/layout/content_main.xml b/Jetchat/app/src/main/res/layout/content_main.xml new file mode 100644 index 0000000000..31302894b9 --- /dev/null +++ b/Jetchat/app/src/main/res/layout/content_main.xml @@ -0,0 +1,34 @@ + + + + + + + + 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/menu/activity_main_drawer.xml b/Jetchat/app/src/main/res/menu/activity_main_drawer.xml new file mode 100644 index 0000000000..a441973a8b --- /dev/null +++ b/Jetchat/app/src/main/res/menu/activity_main_drawer.xml @@ -0,0 +1,32 @@ + + + + + + + + + + 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 new file mode 100644 index 0000000000..c78bee3b53 --- /dev/null +++ b/Jetchat/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Jetchat/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Jetchat/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..6526af635d Binary files /dev/null and b/Jetchat/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Jetchat/app/src/main/res/navigation/mobile_navigation.xml b/Jetchat/app/src/main/res/navigation/mobile_navigation.xml new file mode 100644 index 0000000000..3db49d9468 --- /dev/null +++ b/Jetchat/app/src/main/res/navigation/mobile_navigation.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/Jetchat/app/src/main/res/values-night/colors.xml b/Jetchat/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..be80f0a2ef --- /dev/null +++ b/Jetchat/app/src/main/res/values-night/colors.xml @@ -0,0 +1,22 @@ + + + + + + @color/yellow400 + @color/blue300 + diff --git a/Jetchat/app/src/main/res/values-night/themes.xml b/Jetchat/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..1f512489cf --- /dev/null +++ b/Jetchat/app/src/main/res/values-night/themes.xml @@ -0,0 +1,26 @@ + + + + + + + + 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 new file mode 100644 index 0000000000..0263ad1720 --- /dev/null +++ b/Jetchat/app/src/main/res/values-v23/themes.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/Jetchat/app/src/main/res/values-v27/themes.xml b/Jetchat/app/src/main/res/values-v27/themes.xml new file mode 100644 index 0000000000..a5d1c47d6c --- /dev/null +++ b/Jetchat/app/src/main/res/values-v27/themes.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/Jetchat/app/src/main/res/values/colors.xml b/Jetchat/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..ea7daafd89 --- /dev/null +++ b/Jetchat/app/src/main/res/values/colors.xml @@ -0,0 +1,35 @@ + + + + + + #0540F2 + #001CCF + #F3B711 + + #6F7EF9 + #4860F7 + #F6E547 + + + @color/yellow700 + @color/blue500 + + + #4D000000 + + diff --git a/Jetchat/app/src/main/res/values/dimens.xml b/Jetchat/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..a39994eb8d --- /dev/null +++ b/Jetchat/app/src/main/res/values/dimens.xml @@ -0,0 +1,25 @@ + + + + + 16dp + 16dp + 16dp + 8dp + 24dp + 16dp + diff --git a/Jetchat/app/src/main/res/values/ic_launcher_background.xml b/Jetchat/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..60394aa9f7 --- /dev/null +++ b/Jetchat/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,20 @@ + + + + + #0540F2 + \ No newline at end of file diff --git a/Jetchat/app/src/main/res/values/ids.xml b/Jetchat/app/src/main/res/values/ids.xml new file mode 100644 index 0000000000..a749058c41 --- /dev/null +++ b/Jetchat/app/src/main/res/values/ids.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..bc04038a7a --- /dev/null +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -0,0 +1,73 @@ + + + + Jetchat + + Open navigation drawer + Close navigation drawer + Composer + android.studio@android.com + Navigation header + Settings + + Home + Gallery + Slideshow + Conversations + Profile + Jump to bottom + Send + me + 8:30 PM + %d members + Message #composers + ◀ Swipe to cancel + Emojis + Stickers + + Message + Edit Profile + There was an error loading the profile + Bio + Display name + Status + Timezone + Twitter + Channels in common + Lorem or Ipsum + + + + Emoji selector + Show Emoji selector + Direct Message + Attach Photo + Location selector + Start videochat + Text input + Functionality currently not available + Grab a beverage and check back later! + Attached image + Search + Information + More options + Touch and hold to record + Record voice message + JetChat unread messages + Add Widget to Home Page + + diff --git a/Jetchat/app/src/main/res/values/themes.xml b/Jetchat/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..0e815ceaa4 --- /dev/null +++ b/Jetchat/app/src/main/res/values/themes.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + diff --git a/Jetsnack/build.gradle.kts b/Jetsnack/build.gradle.kts new file mode 100644 index 0000000000..08ccea3e70 --- /dev/null +++ b/Jetsnack/build.gradle.kts @@ -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 + * + * 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.android) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.compose) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") diff --git a/Jetsnack/buildscripts/init.gradle.kts b/Jetsnack/buildscripts/init.gradle.kts new file mode 100644 index 0000000000..1b7a54264c --- /dev/null +++ b/Jetsnack/buildscripts/init.gradle.kts @@ -0,0 +1,72 @@ +/* + * 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 ktlintVersion = "0.46.1" + +initscript { + val spotlessVersion = "6.10.0" + + repositories { + mavenCentral() + } + + dependencies { + classpath("com.diffplug.spotless:spotless-plugin-gradle:$spotlessVersion") + } +} + +allprojects { + if (this == rootProject) { + return@allprojects + } + apply() + extensions.configure { + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt") + ktlint(ktlintVersion).editorConfigOverride( + mapOf( + "ktlint_code_style" to "android", + "ij_kotlin_allow_trailing_comma" to true, + // These rules were introduced in ktlint 0.46.0 and should not be + // enabled without further discussion. They are disabled for now. + // See: https://github.com/pinterest/ktlint/releases/tag/0.46.0 + "disabled_rules" to + "filename," + + "annotation,annotation-spacing," + + "argument-list-wrapping," + + "double-colon-spacing," + + "enum-entry-name-case," + + "multiline-if-else," + + "no-empty-first-line-in-method-block," + + "package-name," + + "trailing-comma," + + "spacing-around-angle-brackets," + + "spacing-between-declarations-with-annotations," + + "spacing-between-declarations-with-comments," + + "unary-op-spacing" + ) + ) + licenseHeaderFile(rootProject.file("spotless/copyright.kt")) + } + format("kts") { + target("**/*.kts") + targetExclude("**/build/**/*.kts") + // Look for the first line that doesn't have a block comment (assumed to be the license) + licenseHeaderFile(rootProject.file("spotless/copyright.kt"), "(^(?![\\/ ]\\*).*$)") + } + } +} \ No newline at end of file diff --git a/Jetsnack/buildscripts/toml-updater-config.gradle b/Jetsnack/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..2934311c61 --- /dev/null +++ b/Jetsnack/buildscripts/toml-updater-config.gradle @@ -0,0 +1,45 @@ +/* + * 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) + // keep all libraries that aren't used in the project + keepUnusedLibraries.set(true) + // keep all plugins that aren't used in the project + keepUnusedPlugins.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_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 new file mode 100644 index 0000000000..9299bc6d0f --- /dev/null +++ b/Jetsnack/gradle.properties @@ -0,0 +1,39 @@ +# +# 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 diff --git a/Jetsnack/gradle/libs.versions.toml b/Jetsnack/gradle/libs.versions.toml new file mode 100644 index 0000000000..29300201d3 --- /dev/null +++ b/Jetsnack/gradle/libs.versions.toml @@ -0,0 +1,174 @@ +##### +# 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.0" +androidGradlePlugin = "8.8.1" +androidx-activity-compose = "1.10.0" +androidx-appcompat = "1.7.0" +androidx-compose-bom = "2025.02.00" +androidx-constraintlayout = "1.1.0" +androidx-core-splashscreen = "1.0.1" +androidx-corektx = "1.15.0" +androidx-glance = "1.1.1" +androidx-lifecycle = "2.8.2" +androidx-lifecycle-compose = "2.8.7" +androidx-lifecycle-runtime-compose = "2.8.7" +androidx-navigation = "2.8.7" +androidx-palette = "1.0.0" +androidx-test = "1.6.1" +androidx-test-espresso = "3.6.1" +androidx-test-ext-junit = "1.2.1" +androidx-test-ext-truth = "1.6.0" +androidx-tv-foundation = "1.0.0-alpha11" +androidx-tv-material = "1.0.0" +androidx-wear-compose = "1.4.1" +androidx-window = "1.3.0" +androidxHiltNavigationCompose = "1.2.0" +androix-test-uiautomator = "2.3.0" +coil = "2.7.0" +# @keep +compileSdk = "35" +coroutines = "1.10.1" +google-maps = "18.2.0" +gradle-versions = "0.52.0" +hilt = "2.55" +hiltExt = "1.2.0" +horologist = "0.6.22" +# @pin When updating to AGP 7.4.0-alpha10 and up we can update this https://developer.android.com/studio/write/java8-support#library-desugaring-versions +jdkDesugar = "1.2.2" +junit = "4.13.2" +kotlin = "2.1.10" +kotlinx-serialization-json = "1.7.3" +kotlinx_immutable = "0.3.8" +ksp = "2.1.10-1.0.30" +maps-compose = "3.1.1" +# @keep +minSdk = "21" +okhttp = "4.12.0" +play-services-wearable = "18.1.0" +robolectric = "4.14.1" +roborazzi = "1.42.0" +rome = "2.1.0" +room = "2.6.1" +secrets = "2.0.1" +# @keep +targetSdk = "33" +version-catalog-update = "0.8.5" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +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 = "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-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" } +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 new file mode 100644 index 0000000000..7454180f2a Binary files /dev/null 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 new file mode 100644 index 0000000000..ba11905c1d --- /dev/null +++ b/Jetsnack/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +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-8.10.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Jetsnack/gradlew b/Jetsnack/gradlew new file mode 100755 index 0000000000..744e882ed5 --- /dev/null +++ b/Jetsnack/gradlew @@ -0,0 +1,185 @@ +#!/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 + ;; + 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" + 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/Jetsnack/gradlew.bat b/Jetsnack/gradlew.bat new file mode 100644 index 0000000000..ac1b06f938 --- /dev/null +++ b/Jetsnack/gradlew.bat @@ -0,0 +1,89 @@ +@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/Jetsnack/screenshots/color_system.png b/Jetsnack/screenshots/color_system.png new file mode 100644 index 0000000000..1841002857 Binary files /dev/null and b/Jetsnack/screenshots/color_system.png differ diff --git a/Jetsnack/screenshots/jetsnack.gif b/Jetsnack/screenshots/jetsnack.gif new file mode 100644 index 0000000000..609f063597 Binary files /dev/null and b/Jetsnack/screenshots/jetsnack.gif differ 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/screenshots/snack_details.gif b/Jetsnack/screenshots/snack_details.gif new file mode 100644 index 0000000000..8b6602c5ed Binary files /dev/null and b/Jetsnack/screenshots/snack_details.gif differ 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/Jetsnack/spotless/copyright.kt b/Jetsnack/spotless/copyright.kt new file mode 100644 index 0000000000..806db0fb54 --- /dev/null +++ b/Jetsnack/spotless/copyright.kt @@ -0,0 +1,16 @@ +/* + * 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/LICENSE b/LICENSE index 1a8cae9207..79b3dd5a8e 100644 --- a/LICENSE +++ b/LICENSE @@ -174,7 +174,18 @@ END OF TERMS AND CONDITIONS - Copyright 2019 Google LLC + 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,5 +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/README.md b/README.md index 37df877b44..0c4431e726 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,121 @@ -Compose Samples Repository -===================== +# Jetpack Compose Samples +Jetpack Compose Samples This repository contains a set of individual Android Studio projects to help you learn about -Compose in Android. +Compose in Android. Each sample demonstrates different use cases, complexity levels and APIs. -For more information, please [read our documentation](https://developer.android.com/jetpack/compose) +For more information, please [read the documentation](https://developer.android.com/jetpack/compose). -Samples -------- -[Jetnews](JetNews/): A sample blog post viewer that demonstrates the use of Compose. \ No newline at end of file +💻 Requirements +------------ +To try out these sample apps, you need to use [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). + +🧬 Samples +------------ + +| Project | | +|:-----|---------| +|
JetNews

A sample blog post viewer that demonstrates the use of Compose with a typical Material app and real-world architecture.

• Medium complexity
• Varied UI
• Light & dark themes
• Resource loading
• UI Testing

**[> Browse](JetNews/)**

| Jetnews sample demo | +| | | +|
Jetchat

A sample chat app that focuses on UI state patterns and text input.

• Low complexity
• Material Design 3 theme and Material You dynamic color
• Resource loading
• Back button handling
• Integration with Architecture Components: Navigation, Fragments, LiveData, ViewModel
• Animation
• UI Testing

**[> Browse](Jetchat/)**

| Jetchat sample demo| +| | | +|
Jetsnack

Jetsnack is a sample snack ordering app built with Compose.

• Medium complexity
• Custom design system
• Custom layouts
• Animation

**[> Browse](Jetsnack/)**

| Jetsnack sample demo| +| | | +|
Jetcaster

A sample podcast app that features a full-featured, Redux-style architecture and showcases dynamic themes.

• Advanced sample
• Dynamic theming using podcast artwork
• Image fetching
• [`WindowInsets`](https://developer.android.com/reference/kotlin/android/view/WindowInsets) support
• Coroutines
• Local storage with Room

**[> Browse](Jetcaster/)**

| Jetcaster sample demo| +| | | +|
Reply

A compose implementation of the Reply material study, an email client app that focuses on adaptive design for mobile, tablets and foldables. It also showcases brand new Material design 3 theming, dynamic colors and navigation components.

• Medium complexity
• Adaptive UI for phones, tablet and desktops
• Foldable support
• Material 3 theming & Components
• Dynamic colors and Light/Dark theme support

**[> Browse](Reply/)**

| Reply sample demo| +| | | +|
JetLagged

A sample sleep tracker app, showcasing how to create custom layouts and graphics in Compose

• Custom Layouts
• Graphs with Paths

**[> Browse](JetLagged/)**

| JetLagged sample demo| + +🧬 Additional samples +------------ + +| Project | | +|:-----|---------| +|
Now in Android

An app for keeping up to date with the latest news and developments in Android.

• [Jetpack Compose](https://developer.android.com/jetpack/compose) first app.
• Implements the recommended Android [Architecture Guidelines](https://developer.android.com/topic/architecture)
• Integrates [Jetpack Libraries](https://developer.android.com/jetpack) holistically in the context of a real world app


**[> Browse](https://github.com/android/nowinandroid)**

| Now In Android Github Repository| +| | | +|
Material Catalog

A catalog of Material Design components and features available in Jetpack Compose. See how to implement them and how they look and behave on real devices.

• Lives in AOSP—always up to date
• Uses the same samples as API reference docs
• Theme picker to change Material Theming values at runtime
• Links to guidelines, docs, source code, and issue tracker


**[> Browse on AOSP](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/integration-tests/material-catalog)**

| Material Catalog sample demo| + + +## High level features + +Looking for a sample that has the following features? + +### Custom Layouts +* [Jetnews: Interests Screen](https://github.com/android/compose-samples/blob/ee198110d8a7575da281de9bd0f84e91970468ca/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt#L428) +* [Jetchat: AnimatedFabContent](https://github.com/android/compose-samples/blob/ee198110d8a7575da281de9bd0f84e91970468ca/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt#L101) +* [Jetsnack: Grid](https://github.com/android/compose-samples/blob/73d7f25815e6936e0e815ce975905a6f10744c36/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt#L27) +* [Jetsnack: CollapsingImageLayout](https://github.com/android/compose-samples/blob/main/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt) + +### Theming +* [Jetchat: Material3](https://github.com/android/compose-samples/blob/main/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt#L91) +* [Jetcaster: Custom theme based on cover art](https://github.com/android/compose-samples/blob/main/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt) +* [Jetsnack: Custom Design System](https://github.com/android/compose-samples/blob/main/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt) + +### Animations +* [Jetsurvey: AnimatedContent](https://github.com/android/compose-samples/pull/842) +* [Jetcaster: Animated theme colors](https://github.com/android/compose-samples/blob/69e9d862b5ffb321064364d7883e859db6daeccd/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt) +* [Jetsnack: Animating Bottom Barl](https://github.com/android/compose-samples/blob/main/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt) + +### Text +* [Jetchat: Downloadable Fonts](https://github.com/android/compose-samples/pull/787) + +### Large Screens +* [Jetcaster - Supporting Pane](https://github.com/android/compose-samples/blob/3dbbf0912b57dacefcfb79191a2d7d6b053dadb8/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt#L282) +* [Jetnews - Window Size Classes](https://github.com/android/compose-samples/blob/69e9d862b5ffb321064364d7883e859db6daeccd/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt#L36) + +### TV +* [Jetcaster - TV](https://github.com/android/compose-samples/tree/3dbbf0912b57dacefcfb79191a2d7d6b053dadb8/Jetcaster/tv-app) + +### Wear +* [Jetcaster - Wear](https://github.com/android/compose-samples/tree/3dbbf0912b57dacefcfb79191a2d7d6b053dadb8/Jetcaster/wear) + +## Formatting + +To automatically format all samples: Run `./scripts/format.sh` +To check one sample for errors: Navigate to the sample folder and run `./gradlew --init-script buildscripts/init.gradle.kts spotlessCheck` +To format one sample: Navigate to the sample folder and run `./gradlew --init-script buildscripts/init.gradle.kts spotlessApply` + +## Updates + +To update dependencies to their new stable versions, run: + +``` +./scripts/updateDeps.sh +``` + +To make any other manual updates to dependencies (ie add a new dependency or set an alpha version), update the `/scripts/libs.versions.toml` file with changes, and then run `duplicate_version_config.sh` to propogate the changes to all other samples. You can also update the `toml-updater-config.gradle` file with changes that need to propogate to each sample. + +## Obsolete Sample Projects + +Over time some of our samples become a little stale and are removed to keep the +repository easy to navigate. If you are curious you can still find them in the +history, however if you are new you might be better served sticking to +the most up to date resources. + +| Project | Removed | Commit | +| ------------------------------------------------ | -----------|-------------------------------------------------------------------- | +| [Crane](../../../tree/v2024.05.00/Crane) | 2024-08-02 | [ee8e272](../../../commit/ee8e27289f4bc36304ee9f04397f49c35f402a65) | +| [Owl](../../../tree/v2024.05.00/Owl) | 2024-08-02 | [ee8e272](../../../commit/ee8e27289f4bc36304ee9f04397f49c35f402a65) | +| [Jetsurvey](../../../tree/v2024.05.00/Jetsurvey) | 2024-08-02 | [ee8e272](../../../commit/ee8e27289f4bc36304ee9f04397f49c35f402a65) | +| [Rally](../../../tree/v2024.05.00/Rally) | 2024-08-02 | [ee8e272](../../../commit/ee8e27289f4bc36304ee9f04397f49c35f402a65) | + +## License +``` +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. +``` 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/Reply/ASSETS_LICENSE b/Reply/ASSETS_LICENSE new file mode 100644 index 0000000000..e7fc95866c --- /dev/null +++ b/Reply/ASSETS_LICENSE @@ -0,0 +1,88 @@ +All font files are licensed under the SIL OPEN FONT LICENSE license. All other files are licensed under the Apache 2 license. + + +SIL OPEN FONT LICENSE +Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file 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..fe425fde09 --- /dev/null +++ b/Reply/app/build.gradle.kts @@ -0,0 +1,136 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.android.application) + alias(libs.plugins.kotlin.android) + 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 + } + + kotlinOptions { + jvmTarget = "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.compose.material.iconsExtended) + + 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..3e535d52a1 --- /dev/null +++ b/Reply/app/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + 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..a9b742277e --- /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..f1e6f3ee49 --- /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..d0f6f89d45 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/EmailAttachment.kt @@ -0,0 +1,27 @@ +/* + * 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..a5a275e6e8 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/data/MailboxType.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 + +/** + * 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..63b9569c39 --- /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..a8b7c6750a --- /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..a4f0d222ff --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/EmptyComingSoon.kt @@ -0,0 +1,64 @@ +/* + * 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..c3cd2e5709 --- /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..5805183e7c --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/ReplyApp.kt @@ -0,0 +1,151 @@ +/* + * 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..c470a60cf4 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/ReplyHomeViewModel.kt @@ -0,0 +1,94 @@ +/* + * 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..8069b7af7a --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/ReplyListContent.kt @@ -0,0 +1,231 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +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.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(Icons.Default.Edit, 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..6974a9c8d9 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyAppBars.kt @@ -0,0 +1,239 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +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.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( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back_button), + modifier = Modifier + .padding(start = 16.dp) + .clickable { + expanded = false + query = "" + }, + ) + } else { + Icon( + imageVector = Icons.Default.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( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back_button), + modifier = Modifier.size(14.dp) + ) + } + } + }, + actions = { + IconButton( + onClick = { /*TODO*/ }, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + 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..ba2c6299fe --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyEmailListItem.kt @@ -0,0 +1,162 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.StarBorder +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.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.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") { selected -> + if (selected) { + 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( + imageVector = Icons.Default.StarBorder, + 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( + Icons.Default.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..a3fbac594d --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyEmailThreadItem.kt @@ -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. + */ + +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.material.icons.Icons +import androidx.compose.material.icons.filled.StarBorder +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.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( + imageVector = Icons.Default.StarBorder, + 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..de83690939 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/components/ReplyProfileImage.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.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..72d9b2c097 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationActions.kt @@ -0,0 +1,89 @@ +/* + * 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.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.ui.graphics.vector.ImageVector +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: ImageVector, + val unselectedIcon: ImageVector, + 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 = Icons.Default.Inbox, + unselectedIcon = Icons.Default.Inbox, + iconTextId = R.string.tab_inbox + ), + ReplyTopLevelDestination( + route = Route.Articles, + selectedIcon = Icons.AutoMirrored.Filled.Article, + unselectedIcon = Icons.AutoMirrored.Filled.Article, + iconTextId = R.string.tab_article + ), + ReplyTopLevelDestination( + route = Route.DirectMessages, + selectedIcon = Icons.Outlined.ChatBubbleOutline, + unselectedIcon = Icons.Outlined.ChatBubbleOutline, + iconTextId = R.string.tab_inbox + ), + ReplyTopLevelDestination( + route = Route.Groups, + selectedIcon = Icons.Default.People, + unselectedIcon = Icons.Default.People, + iconTextId = R.string.tab_article + ) + +) 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..3cd82fc83d --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/navigation/ReplyNavigationComponents.kt @@ -0,0 +1,486 @@ +/* + * 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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuOpen +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Menu +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.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.WindowHeightSizeClass +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass +import com.example.reply.R +import com.example.reply.ui.utils.ReplyNavigationContentPosition +import kotlinx.coroutines.launch + +private fun WindowSizeClass.isCompact() = + windowWidthSizeClass == WindowWidthSizeClass.COMPACT || + windowHeightSizeClass == WindowHeightSizeClass.COMPACT + +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.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED && + windowSize.width >= 1200.dp -> NavigationSuiteType.NavigationDrawer + else -> NavigationSuiteType.NavigationRail + } + val navContentPosition = when (adaptiveInfo.windowSizeClass.windowHeightSizeClass) { + WindowHeightSizeClass.COMPACT -> ReplyNavigationContentPosition.TOP + WindowHeightSizeClass.MEDIUM, + WindowHeightSizeClass.EXPANDED -> 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( + imageVector = Icons.Default.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( + imageVector = Icons.Default.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( + imageVector = 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( + imageVector = 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( + imageVector = Icons.Default.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( + imageVector = 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( + imageVector = Icons.AutoMirrored.Filled.MenuOpen, + 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( + imageVector = Icons.Default.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( + imageVector = 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..0c11182f2b --- /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..5118734c24 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/theme/Theme.kt @@ -0,0 +1,324 @@ +/* + * 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) + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = replyColorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = 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..a180cb8731 --- /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..f212254689 --- /dev/null +++ b/Reply/app/src/main/java/com/example/reply/ui/utils/WindowStateUtils.kt @@ -0,0 +1,72 @@ +/* + * 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_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/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 @@ + + + + +