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 #<issue_number_goes_here> 🦕 diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml new file mode 100644 index 0000000000..0171c48bbc --- /dev/null +++ b/.github/blunderbuss.yml @@ -0,0 +1,3 @@ +assign_issues: + - android/compose-devrel + diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties index 5dd327d5c8..9ba9603f34 100644 --- a/.github/ci-gradle.properties +++ b/.github/ci-gradle.properties @@ -21,3 +21,6 @@ org.gradle.workers.max=2 kotlin.incremental=false kotlin.compiler.execution.strategy=in-process + +# Controls KotlinOptions.allWarningsAsErrors. This is used in CI and can be set in local properties. +warningsAsErrors=true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..0d08e261a2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000..824f9f7ebf --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,61 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 60 + +# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. +daysUntilClose: 7 + +# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) +onlyLabels: [] + +# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable +exemptLabels: + - pinned + - security + - "[Status] Maybe Later" + +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: false + +# Set to true to ignore issues in a milestone (defaults to false) +exemptMilestones: false + +# Set to true to ignore issues with an assignee (defaults to false) +exemptAssignees: false + +# Label to use when marking as stale +staleLabel: stale + +# Comment to post when marking as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had any + recent activity. Please comment here if it is still valid so that we can reprioritize it. Thank you + for your contributions. + +# Comment to post when removing the stale label. +# unmarkComment: > +# Your comment here. + +# Comment to post when closing a stale Issue or Pull Request. +# closeComment: > +# Your comment here. + +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 30 + +# Limit to only `issues` or `pulls` +# only: issues + +# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': +# pulls: +# daysUntilStale: 30 +# markComment: > +# This pull request has been automatically marked as stale because it has not had +# recent activity. It will be closed if no further activity occurs. Thank you +# for your contributions. + +# issues: +# exemptLabels: +# - confirmed diff --git a/.github/workflows/Crane.yaml b/.github/workflows/Crane.yaml deleted file mode 100644 index 14eaa93f38..0000000000 --- a/.github/workflows/Crane.yaml +++ /dev/null @@ -1,107 +0,0 @@ -name: Crane - -on: - push: - branches: - - master - paths: - - 'Crane/**' - pull_request: - paths: - - 'Crane/**' - -env: - SAMPLE_PATH: Crane - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports - - test: - needs: build - runs-on: macOS-latest # enables hardware acceleration in the virtual machine - timeout-minutes: 30 - strategy: - matrix: - api-level: [23, 26, 29] - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - target: google_apis - arch: x86 - disable-animations: true - script: ./gradlew connectedCheck --stacktrace - working-directory: ${{ env.SAMPLE_PATH }} - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: test-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/JetLagged.yaml b/.github/workflows/JetLagged.yaml new file mode 100644 index 0000000000..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 index f13ba45b35..a444e4e5d8 100644 --- a/.github/workflows/JetNews.yaml +++ b/.github/workflows/JetNews.yaml @@ -3,64 +3,35 @@ name: JetNews on: push: branches: - - master + - main paths: + - '.github/workflows/JetNews.yaml' - 'JetNews/**' pull_request: paths: + - '.github/workflows/JetNews.yaml' - 'JetNews/**' + workflow_dispatch: env: SAMPLE_PATH: JetNews - + compose_store_password: ${{ secrets.compose_store_password }} + compose_key_alias: ${{ secrets.compose_key_alias }} + compose_key_password: ${{ secrets.compose_key_password }} jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports - - test: + uses: ./.github/workflows/build-sample.yml + with: + name: JetNews + path: JetNews + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} + + androidTest: needs: build - runs-on: macOS-latest # enables hardware acceleration in the virtual machine + runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: @@ -68,27 +39,31 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 - + uses: actions/checkout@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 11 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: 11 + java-version: 17 + distribution: 'zulu' - name: Generate cache key run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - uses: actions/cache@v2 + - uses: actions/cache@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: @@ -100,7 +75,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: test-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports + name: test-reports-jetnews-${{ matrix.api-level }} + path: ${{ env.SAMPLE_PATH }}/app/build/reports/androidTests diff --git a/.github/workflows/Jetcaster.yaml b/.github/workflows/Jetcaster.yaml index 0567271df8..44c15fe1ce 100644 --- a/.github/workflows/Jetcaster.yaml +++ b/.github/workflows/Jetcaster.yaml @@ -3,57 +3,24 @@ name: Jetcaster on: push: branches: - - master + - main paths: + - '.github/workflows/Jetcaster.yaml' - 'Jetcaster/**' pull_request: paths: + - '.github/workflows/Jetcaster.yaml' - 'Jetcaster/**' - -env: - SAMPLE_PATH: Jetcaster + workflow_dispatch: jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports + uses: ./.github/workflows/build-sample.yml + with: + name: Jetcaster + path: Jetcaster + module: mobile + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} diff --git a/.github/workflows/Jetchat.yaml b/.github/workflows/Jetchat.yaml index f5c04795c0..feb22ef6eb 100644 --- a/.github/workflows/Jetchat.yaml +++ b/.github/workflows/Jetchat.yaml @@ -3,64 +3,34 @@ name: Jetchat on: push: branches: - - master + - main paths: + - '.github/workflows/Jetchat.yaml' - 'Jetchat/**' pull_request: paths: + - '.github/workflows/Jetchat.yaml' - 'Jetchat/**' + workflow_dispatch: env: SAMPLE_PATH: Jetchat - + compose_store_password: ${{ secrets.compose_store_password }} + compose_key_alias: ${{ secrets.compose_key_alias }} + compose_key_password: ${{ secrets.compose_key_password }} jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports - + uses: ./.github/workflows/build-sample.yml + with: + name: Jetchat + path: Jetchat + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} test: needs: build - runs-on: macOS-latest # enables hardware acceleration in the virtual machine + runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: @@ -68,20 +38,21 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: 11 + java-version: 17 + distribution: 'zulu' - name: Generate cache key run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.gradle/caches/modules-* @@ -100,7 +71,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: test-reports + name: test-reports-jetchat-${{ matrix.api-level }} path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/Jetsnack.yaml b/.github/workflows/Jetsnack.yaml index ff1239845d..1962c2c789 100644 --- a/.github/workflows/Jetsnack.yaml +++ b/.github/workflows/Jetsnack.yaml @@ -3,57 +3,23 @@ name: Jetsnack on: push: branches: - - master + - main paths: + - '.github/workflows/Jetsnack.yaml' - 'Jetsnack/**' pull_request: paths: + - '.github/workflows/Jetsnack.yaml' - 'Jetsnack/**' - -env: - SAMPLE_PATH: Jetsnack + workflow_dispatch: jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports + uses: ./.github/workflows/build-sample.yml + with: + name: Jetsnack + path: Jetsnack + secrets: + compose_store_password: ${{ secrets.COMPOSE_STORE_PASSWORD }} + compose_key_alias: ${{ secrets.COMPOSE_KEY_ALIAS }} + compose_key_password: ${{ secrets.COMPOSE_KEY_PASSWORD }} diff --git a/.github/workflows/Jetsurvey.yaml b/.github/workflows/Jetsurvey.yaml deleted file mode 100644 index 5ac3fa99c5..0000000000 --- a/.github/workflows/Jetsurvey.yaml +++ /dev/null @@ -1,59 +0,0 @@ -name: Jetsurvey - -on: - push: - branches: - - master - paths: - - 'Jetsurvey/**' - pull_request: - paths: - - 'Jetsurvey/**' - -env: - SAMPLE_PATH: Jetsurvey - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/Owl.yaml b/.github/workflows/Owl.yaml deleted file mode 100644 index 328a24b276..0000000000 --- a/.github/workflows/Owl.yaml +++ /dev/null @@ -1,106 +0,0 @@ -name: Owl - -on: - push: - branches: - - master - paths: - - 'Owl/**' - pull_request: - paths: - - 'Owl/**' - -env: - SAMPLE_PATH: Owl - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports - - test: - needs: build - runs-on: macOS-latest # enables hardware acceleration in the virtual machine - timeout-minutes: 30 - strategy: - matrix: - api-level: [23, 26, 29] - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - arch: x86 - disable-animations: true - script: ./gradlew connectedCheck --stacktrace - working-directory: ${{ env.SAMPLE_PATH }} - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: test-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/Rally.yaml b/.github/workflows/Rally.yaml deleted file mode 100644 index 464b43a88c..0000000000 --- a/.github/workflows/Rally.yaml +++ /dev/null @@ -1,59 +0,0 @@ -name: Rally - -on: - push: - branches: - - master - paths: - - 'Rally/**' - pull_request: - paths: - - 'Rally/**' - -env: - SAMPLE_PATH: Rally - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Copy CI gradle.properties - run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - - name: Generate cache key - run: ./scripts/checksum.sh $SAMPLE_PATH checksum.txt - - - uses: actions/cache@v2 - with: - path: | - ~/.gradle/caches/modules-* - ~/.gradle/caches/jars-* - ~/.gradle/caches/build-cache-* - key: gradle-${{ hashFiles('checksum.txt') }} - - - name: Build project - working-directory: ${{ env.SAMPLE_PATH }} - run: ./gradlew spotlessCheck assembleDebug lintDebug --stacktrace - - - name: Upload build outputs (APKs) - uses: actions/upload-artifact@v2 - with: - name: build-outputs - path: ${{ env.SAMPLE_PATH }}/app/build/outputs - - - name: Upload build reports - if: always() - uses: actions/upload-artifact@v2 - with: - name: build-reports - path: ${{ env.SAMPLE_PATH }}/app/build/reports diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index f0aa853764..363c9fb837 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -4,7 +4,10 @@ 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 @@ -12,15 +15,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + 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 11 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: 11 + java-version: 17 + distribution: 'zulu' - name: Build all projects run: ./scripts/gradlew_recursive.sh assembleDebug @@ -36,43 +40,13 @@ jobs: draft: true prerelease: false - - name: Upload Crane - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Crane/app/build/outputs/apk/debug/app-debug.apk - asset_name: crane-debug.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload Owl - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Owl/app/build/outputs/apk/debug/app-debug.apk - asset_name: owl-debug.apk - asset_content_type: application/vnd.android.package-archive - - - name: Upload Rally - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Rally/app/build/outputs/apk/debug/app-debug.apk - asset_name: rally-debug.apk - asset_content_type: application/vnd.android.package-archive - - name: Upload Jetcaster uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Jetcaster/app/build/outputs/apk/debug/app-debug.apk + asset_path: Jetcaster/mobile/build/outputs/apk/debug/app-debug.apk asset_name: jetcaster-debug.apk asset_content_type: application/vnd.android.package-archive @@ -105,13 +79,3 @@ jobs: asset_path: Jetsnack/app/build/outputs/apk/debug/app-debug.apk asset_name: jetsnack-debug.apk asset_content_type: application/vnd.android.package-archive - - - name: Upload Jetsurvey - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: Jetsurvey/app/build/outputs/apk/debug/app-debug.apk - asset_name: jetsurvey-debug.apk - asset_content_type: application/vnd.android.package-archive 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..4d0de3cb44 --- /dev/null +++ b/.github/workflows/build-sample.yml @@ -0,0 +1,95 @@ +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 + with: + # https://github.com/diffplug/spotless/issues/710 + # Check out full history for Spotless to ensure ratchetFrom can find the ratchet version + fetch-depth: 0 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Set up JDK 17 + uses: actions/setup-java@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 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 index f8f8572d9a..46a0f90d3a 100644 --- a/.github/workflows/copy-branch.yml +++ b/.github/workflows/copy-branch.yml @@ -19,7 +19,7 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it, # but specifies master branch (old default). - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 ref: master 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 <compose-devrel-github-bot@google.com> + author: compose-devrel-github-bot <compose-devrel-github-bot@google.com> + 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 <https://opensource.google/conduct/>.* + +[IndieWeb Code of Conduct]: https://indieweb.org/code-of-conduct diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3196023998..f23eb506de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ accept your pull requests. ## Contributing A Patch -All development is done on the latest `dev_XX` branch. You should base any changes from this branch. +All development is done on the `main` branch. You should base any changes from this branch. 1. Submit an issue describing your proposed change to the repo in question. 1. The repo owner will respond to your issue promptly. @@ -30,4 +30,4 @@ All development is done on the latest `dev_XX` branch. You should base any chang [Kotlin Style Guide](https://android.github.io/kotlin-guides/style.html) for the recommended coding standards for this organization. 1. Ensure that your code has an appropriate set of unit tests which all pass. -1. Submit a pull request targeting the latest `dev_XX` branch. +1. Submit a pull request targeting the `main` branch. diff --git a/Crane/.gitignore b/Crane/.gitignore deleted file mode 100644 index 73652c1603..0000000000 --- a/Crane/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea -.DS_Store -/build -/captures -.externalNativeBuild -/studio/ diff --git a/Crane/.google/packaging.yaml b/Crane/.google/packaging.yaml deleted file mode 100644 index 9d138a236b..0000000000 --- a/Crane/.google/packaging.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (C) 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# GOOGLE SAMPLE PACKAGING DATA -# -# This file is used by Google as part of our samples packaging process. -# End users may safely ignore this file. It has no relevance to other systems. ---- -status: PUBLISHED -technologies: [Android] -categories: [Compose] -languages: [Kotlin] -solutions: [Mobile] -github: android/compose-samples -level: INTERMEDIATE -apiRefs: - - android:androidx.compose.Composable -license: apache2 diff --git a/Crane/README.md b/Crane/README.md deleted file mode 100644 index 8525d6afc8..0000000000 --- a/Crane/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# Crane sample - -[Crane](https://material.io/design/material-studies/crane.html) is a travel app part of the Material -Studies built with [Jetpack Compose](https://developer.android.com/jetpack/compose). -The goal of the sample is to showcase Material components, draggable UI elements, Android Views -inside Compose, and UI state handling. - -To try out this sample app, you need to use the latest Canary version of Android Studio 4.2. -You can clone this repository or import the -project from Android Studio following the steps -[here](https://developer.android.com/jetpack/compose/setup#sample). - -## Screenshots - -<img src="screenshots/crane.gif"/> - -## Features - -This sample contains 4 screens: -- __Landing__ [screen][landing] that fades out after 2 seconds then slides the main content in from -the bottom of the screen. -- __Home__ [screen][home] where you can explore flights, hotels, and restaurants specifying -the number of people. - - Clicking on the number of people refreshes the destinations. - - The [backdrop](https://material.io/components/backdrop) is draggable and can pin to the top of - the screen, just under the search criteria, and to the bottom. Implemented [here][backdrop]. - - Destination's images are retrieved using the [coil-accompanist][coil-accompanist] library. -- __Calendar__ [screen][calendar]. Tapping on __Select Dates__ takes you to a calendar built -completely from scratch. It makes a heavy usage of Compose's state APIs. -- Destination's __Details__ [screen][details]. When tapping on a destination, a new screen -implemented using a different Activity will be displayed. In there, you can see the a `MapView` -embedded in Compose and Compose buttons updating the Android View. Notice how you can also -interact with the `MapView` seamlessly. - -## Hilt - -Crane uses [Hilt][hilt] to manage its dependencies. The Hilt's ViewModel extension (with the -`@ViewModelInject` annotation) works perfectly with Compose's ViewModel integration (`viewModel()` -composable function) as you can see in the following snippet of code. `viewModel()` will -automatically use the factory that Hilt creates for the ViewModel: - -``` -class MainViewModel @ViewModelInject constructor( - private val destinationsRepository: DestinationsRepository, - @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, - datesRepository: DatesRepository -) : ViewModel() { ... } - -@Composable -fun CraneHomeContent(...) { - val viewModel: MainViewModel = viewModel() - ... -} -``` - -Disclaimer: Passing dependencies to a ViewModel which are not available at compile time (which is -sometimes called _assisted injection_) doesn't work as you might expect using `viewModel()`. -Compose's ViewModel integration cannot currently scope a ViewModel to a given composable. Instead -it is always scoped to the host Activity or Fragment. This means that calling `viewModel()` with -different factories in the same host Activity/Fragment don't have the desired effect; the _first_ -factory will be always used. - -This is the case of the [DetailsViewModel](detailsViewModel), which takes the name of -a `City` as a parameter to load the required information for the screen. However, the above isn't a -problem in this sample, since `DetailsScreen` is always used in it's own newly launched Activity. - -## Google Maps SDK - -To get the MapView working, you need to get an API key as -the [documentation says](https://developers.google.com/maps/documentation/android-sdk/get-api-key), -and include it in the `local.properties` file as follows: - -``` -google.maps.key={insert_your_api_key_here} -``` - -## Data - -The data is hardcoded in the _CraneData_ [file][data] and exposed to the UI using the -[MainViewModel][mainViewModel]. Image resources are retrieved from -[Unsplash](https://unsplash.com/). - -## Testing - -Crane has Compose-only tests (e.g. [HomeTest][homeTest]) but also tests covering Compose and the -view-based system (e.g. [DetailsActivityTest][detailsTest]). The latter uses the `onActivity` -method of the `ActivityScenarioRule` to access information from the `MapView`. - -## License - -``` -Copyright 2020 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` - -[landing]: app/src/main/java/androidx/compose/samples/crane/home/LandingScreen.kt -[home]: app/src/main/java/androidx/compose/samples/crane/home/CraneHome.kt -[backdrop]: app/src/main/java/androidx/compose/samples/crane/ui/BackdropFrontLayer.kt -[calendar]: app/src/main/java/androidx/compose/samples/crane/calendar/Calendar.kt -[details]: app/src/main/java/androidx/compose/samples/crane/details/DetailsActivity.kt -[data]: app/src/main/java/androidx/compose/samples/crane/data/CraneData.kt -[mainViewModel]: app/src/main/java/androidx/compose/samples/crane/home/MainViewModel.kt -[detailsViewModel]: app/src/main/java/androidx/compose/samples/crane/details/DetailsViewModel.kt -[homeTest]: app/src/androidTest/java/androidx/compose/samples/crane/home/HomeTest.kt -[detailsTest]: app/src/androidTest/java/androidx/compose/samples/crane/details/DetailsActivityTest.kt -[coil-accompanist]: https://github.com/chrisbanes/accompanist -[hilt]: https://d.android.com/hilt diff --git a/Crane/app/build.gradle b/Crane/app/build.gradle deleted file mode 100644 index 6d0b62b582..0000000000 --- a/Crane/app/build.gradle +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright 2019 Google, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.crane.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' - id 'kotlin-kapt' - id 'dagger.hilt.android.plugin' -} - -// Reads the Google maps key that is used in the AndroidManifest -Properties properties = new Properties() -if (rootProject.file("local.properties").exists()) { - properties.load(rootProject.file("local.properties").newDataInputStream()) -} - -android { - compileSdkVersion 30 - defaultConfig { - applicationId "androidx.compose.samples.crane" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName "1.0" - vectorDrawables.useSupportLibrary = true - testInstrumentationRunner "androidx.compose.samples.crane.CustomTestRunner" - - javaCompileOptions { - annotationProcessorOptions { - arguments["dagger.hilt.disableModulesHaveInstallInCheck"] = "true" - } - } - - manifestPlaceholders = [ googleMapsKey : properties.getProperty("google.maps.key", "") ] - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = "1.8" - } - - buildFeatures { - compose true - - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerVersion Libs.Kotlin.version - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } - - packagingOptions { - // Multiple dependency bring these files in. Exclude them to enable - // our test APK to build (has no effect on our AARs) - excludes += "/META-INF/AL2.0" - excludes += "/META-INF/LGPL2.1" - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Kotlin.Coroutines.android - implementation Libs.GoogleMaps.maps - implementation Libs.GoogleMaps.mapsKtx - - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.runtimeLivedata - implementation Libs.AndroidX.Compose.foundation - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.animation - implementation Libs.AndroidX.Compose.tooling - implementation Libs.Accompanist.coil - - implementation Libs.AndroidX.Lifecycle.viewModelKtx - implementation Libs.Hilt.android - implementation Libs.Hilt.AndroidX.viewModel - compileOnly Libs.AssistedInjection.dagger - kapt Libs.Hilt.compiler - kapt Libs.Hilt.AndroidX.compiler - kapt Libs.AssistedInjection.processor - - androidTestImplementation Libs.JUnit.junit - androidTestImplementation Libs.AndroidX.Test.runner - androidTestImplementation Libs.AndroidX.Test.espressoCore - androidTestImplementation Libs.AndroidX.Test.rules - androidTestImplementation Libs.AndroidX.Test.Ext.junit - androidTestImplementation Libs.Kotlin.Coroutines.test - androidTestImplementation Libs.AndroidX.Compose.uiTest - androidTestImplementation Libs.Hilt.android - androidTestImplementation Libs.Hilt.AndroidX.viewModel - androidTestImplementation Libs.Hilt.testing - kaptAndroidTest Libs.Hilt.compiler - kaptAndroidTest Libs.Hilt.AndroidX.compiler - kaptAndroidTest Libs.AssistedInjection.processor -} diff --git a/Crane/app/proguard-rules.pro b/Crane/app/proguard-rules.pro deleted file mode 100644 index 4cb94585a0..0000000000 --- a/Crane/app/proguard-rules.pro +++ /dev/null @@ -1,24 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile - -# Repackage classes into the top-level. --repackageclasses diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/CustomTestRunner.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/CustomTestRunner.kt deleted file mode 100644 index d81394f073..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/CustomTestRunner.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane - -import android.app.Application -import android.content.Context -import androidx.test.runner.AndroidJUnitRunner -import dagger.hilt.android.testing.HiltTestApplication - -class CustomTestRunner : AndroidJUnitRunner() { - override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { - return super.newApplication(cl, HiltTestApplication::class.java.name, context) - } -} diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/calendar/CalendarTest.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/calendar/CalendarTest.kt deleted file mode 100644 index 54c317e7be..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/calendar/CalendarTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar - -import androidx.compose.material.Surface -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.FirstDay -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.FirstLastDay -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.LastDay -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.NoSelected -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus.Selected -import androidx.compose.samples.crane.data.DatesRepository -import androidx.compose.samples.crane.di.DispatchersModule -import androidx.compose.samples.crane.ui.CraneTheme -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assertLabelEquals -import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.UninstallModules -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import javax.inject.Inject - -@UninstallModules(DispatchersModule::class) -@HiltAndroidTest -class CalendarTest { - - @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule<CalendarActivity>() - - @Inject - lateinit var datesRepository: DatesRepository - - @Before - fun setUp() { - hiltRule.inject() - - composeTestRule.setContent { - CraneTheme { - Surface { - CalendarScreen(onBackPressed = {}) - } - } - } - } - - @Test - fun scrollsToTheBottom() { - composeTestRule.onNodeWithContentDescription("January 1").assertExists() - composeTestRule.onNodeWithContentDescription("December 31").performScrollTo().performClick() - assert(datesRepository.datesSelected.toString() == "Dec 31") - } - - @Test - fun onDaySelected() { - composeTestRule.onNodeWithContentDescription("January 1").assertExists() - composeTestRule.onNodeWithContentDescription("January 2").assertExists().performClick() - composeTestRule.onNodeWithContentDescription("January 3").assertExists() - - val datesNoSelected = composeTestRule.onDateNodes(NoSelected) - datesNoSelected[0].assertLabelEquals("January 1") - datesNoSelected[1].assertLabelEquals("January 3") - - composeTestRule.onDateNode(FirstLastDay).assertLabelEquals("January 2") - } - - @Test - fun twoDaysSelected() { - composeTestRule.onNodeWithContentDescription("January 2").assertExists().performClick() - - val datesNoSelectedOneClick = composeTestRule.onDateNodes(NoSelected) - datesNoSelectedOneClick[0].assertLabelEquals("January 1") - datesNoSelectedOneClick[1].assertLabelEquals("January 3") - - composeTestRule.onNodeWithContentDescription("January 4").assertExists().performClick() - - composeTestRule.onDateNode(FirstDay).assertLabelEquals("January 2") - composeTestRule.onDateNode(Selected).assertLabelEquals("January 3") - composeTestRule.onDateNode(LastDay).assertLabelEquals("January 4") - - val datesNoSelected = composeTestRule.onDateNodes(NoSelected) - datesNoSelected[0].assertLabelEquals("January 1") - datesNoSelected[1].assertLabelEquals("January 5") - } -} - -private fun ComposeTestRule.onDateNode(status: DaySelectedStatus) = onNode( - SemanticsMatcher.expectValue(DayStatusKey, status) -) - -private fun ComposeTestRule.onDateNodes(status: DaySelectedStatus) = onAllNodes( - SemanticsMatcher.expectValue(DayStatusKey, status) -) diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/details/DetailsActivityTest.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/details/DetailsActivityTest.kt deleted file mode 100644 index d91c364ee6..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/details/DetailsActivityTest.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.details - -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.data.DestinationsRepository -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.samples.crane.data.MADRID -import androidx.compose.samples.crane.di.DispatchersModule -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onNodeWithText -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.libraries.maps.MapView -import com.google.android.libraries.maps.model.CameraPosition -import com.google.android.libraries.maps.model.LatLng -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.UninstallModules -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.util.concurrent.CountDownLatch -import javax.inject.Inject -import kotlin.math.pow -import kotlin.math.round - -@UninstallModules(DispatchersModule::class) -@HiltAndroidTest -class DetailsActivityTest { - - @Inject - lateinit var destinationsRepository: DestinationsRepository - lateinit var cityDetails: ExploreModel - - private val city = MADRID - private val testExploreModel = ExploreModel(city, "description", "imageUrl") - - @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeTestRule = AndroidComposeTestRule( - activityRule = ActivityScenarioRule<DetailsActivity>( - createDetailsActivityIntent( - InstrumentationRegistry.getInstrumentation().targetContext, - testExploreModel - ) - ), - // Needed for now, discussed in https://issuetracker.google.com/issues/174472899 - activityProvider = { rule -> - var activity: DetailsActivity? = null - rule.scenario.onActivity { activity = it } - if (activity == null) { - throw IllegalStateException("Activity was not set in the ActivityScenarioRule!") - } - activity!! - } - ) - - @Before - fun setUp() { - hiltRule.inject() - cityDetails = destinationsRepository.getDestination(MADRID.name)!! - } - - @Test - fun mapView_cameraPositioned() { - composeTestRule.onNodeWithText(cityDetails.city.nameToDisplay).assertIsDisplayed() - composeTestRule.onNodeWithText(cityDetails.description).assertIsDisplayed() - onView(withId(R.id.map)).check(matches(isDisplayed())) - - var cameraPosition: CameraPosition? = null - waitForMap(onCameraPosition = { cameraPosition = it }) - - val expected = LatLng( - testExploreModel.city.latitude.toDouble(), - testExploreModel.city.longitude.toDouble() - ) - assert(expected.latitude == cameraPosition?.target?.latitude?.round(6)) - assert(expected.longitude == cameraPosition?.target?.longitude?.round(6)) - } - - /** - * As the MapView is included using the AndroidView API, it cannot be referenced using Compose - * testing APIs. Therefore, we use the activityRule to get an instance of the DetailsActivity - * an findViewById using MapView's id. - * - * As obtaining the map is an asynchronous call, we use a CountDownLatch to make this - * call synchronous in the test. - */ - private fun waitForMap(onCameraPosition: (CameraPosition) -> Unit) { - val countDownLatch = CountDownLatch(1) - composeTestRule.activityRule.scenario.onActivity { - it.findViewById<MapView>(R.id.map).getMapAsync { map -> - onCameraPosition(map.cameraPosition) - countDownLatch.countDown() - } - } - countDownLatch.await() - } -} - -private fun Double.round(decimals: Int = 2): Double = - round(this * 10f.pow(decimals)) / 10f.pow(decimals) diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/di/TestDispatchersModule.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/di/TestDispatchersModule.kt deleted file mode 100644 index 554d87b150..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/di/TestDispatchersModule.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:Suppress("DEPRECATION") - -package androidx.compose.samples.crane.di - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi - -@OptIn(ExperimentalCoroutinesApi::class) -@Module -@InstallIn(SingletonComponent::class) -class TestDispatchersModule { - - @Provides - @DefaultDispatcher - fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Unconfined -} diff --git a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/home/HomeTest.kt b/Crane/app/src/androidTest/java/androidx/compose/samples/crane/home/HomeTest.kt deleted file mode 100644 index af4e73489c..0000000000 --- a/Crane/app/src/androidTest/java/androidx/compose/samples/crane/home/HomeTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.samples.crane.di.DispatchersModule -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.UninstallModules -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@UninstallModules(DispatchersModule::class) -@HiltAndroidTest -class HomeTest { - - @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule<MainActivity>() - - @Before - fun setUp() { - composeTestRule.setContent { - MainScreen({ }, { }) - } - } - - @Test - fun home_navigatesToAllScreens() { - composeTestRule.onNodeWithText("Explore Flights by Destination").assertExists() - composeTestRule.onNodeWithText("SLEEP").performClick() - composeTestRule.onNodeWithText("Explore Properties by Destination").assertExists() - composeTestRule.onNodeWithText("EAT").performClick() - composeTestRule.onNodeWithText("Explore Restaurants by Destination").assertExists() - composeTestRule.onNodeWithText("FLY").performClick() - composeTestRule.onNodeWithText("Explore Flights by Destination").assertExists() - } -} diff --git a/Crane/app/src/debug/AndroidManifest.xml b/Crane/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 17fc280f7c..0000000000 --- a/Crane/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - ~ - --> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="androidx.compose.samples.crane"> - - <application> - <!-- Needed for UI tests - https://issuetracker.google.com/162419586 --> - <activity android:name="androidx.activity.ComponentActivity" /> - </application> - -</manifest> \ No newline at end of file diff --git a/Crane/app/src/main/AndroidManifest.xml b/Crane/app/src/main/AndroidManifest.xml deleted file mode 100644 index 7d7b70236e..0000000000 --- a/Crane/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,55 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="androidx.compose.samples.crane"> - - <uses-permission android:name="android.permission.INTERNET" /> - - <!-- - Android 11 package visibility changes require that apps specify which - set of other packages on the device that they can access. Since this - sample uses Google Maps, specifying the Google Maps package name is - required so that the buttons on the Map toolbar launch the Google Maps app. - --> - <queries> - <package android:name="com.google.android.apps.maps" /> - </queries> - - <application - android:name=".CraneApplication" - android:allowBackup="true" - android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" - android:supportsRtl="true" - android:theme="@style/AppTheme"> - - <meta-data android:name="com.google.android.geo.API_KEY" - android:value="${googleMapsKey}"/> - - <activity android:name=".home.MainActivity"> - <intent-filter> - <action android:name="android.intent.action.MAIN" /> - - <category android:name="android.intent.category.LAUNCHER" /> - </intent-filter> - </activity> - <activity android:name=".details.DetailsActivity" /> - <activity android:name=".calendar.CalendarActivity" /> - </application> - -</manifest> diff --git a/Crane/app/src/main/ic_launcher-playstore.png b/Crane/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index acbe14b08a..0000000000 Binary files a/Crane/app/src/main/ic_launcher-playstore.png and /dev/null differ diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/CraneApplication.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/CraneApplication.kt deleted file mode 100644 index 8cb75e6a5e..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/CraneApplication.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane - -import android.app.Application -import dagger.hilt.android.HiltAndroidApp - -@HiltAndroidApp -class CraneApplication : Application() diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/BaseUserInput.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/BaseUserInput.kt deleted file mode 100644 index 9091b5730c..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/BaseUserInput.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.ui.captionTextStyle -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -@Composable -fun SimpleUserInput( - text: String? = null, - caption: String? = null, - @DrawableRes vectorImageId: Int? = null -) { - CraneUserInput( - caption = if (text == null) caption else null, - text = text ?: "", - vectorImageId = vectorImageId - ) -} - -@Composable -fun CraneUserInput( - text: String, - modifier: Modifier = Modifier, - caption: String? = null, - @DrawableRes vectorImageId: Int? = null, - tint: Color = AmbientContentColor.current -) { - CraneBaseUserInput( - modifier = modifier, - caption = caption, - vectorImageId = vectorImageId, - tintIcon = { text.isNotEmpty() }, - tint = tint - ) { - Text(text = text, style = MaterialTheme.typography.body1.copy(color = tint)) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun CraneEditableUserInput( - hint: String, - caption: String? = null, - @DrawableRes vectorImageId: Int? = null, - onInputChanged: (String) -> Unit -) { - var textFieldState by remember { mutableStateOf(TextFieldValue(text = hint)) } - val isHint = { textFieldState.text == hint } - - CraneBaseUserInput( - caption = caption, - tintIcon = { !isHint() }, - showCaption = { !isHint() }, - vectorImageId = vectorImageId - ) { - BasicTextField( - value = textFieldState, - onValueChange = { - textFieldState = it - if (!isHint()) onInputChanged(textFieldState.text) - }, - textStyle = if (isHint()) { - captionTextStyle.copy(color = AmbientContentColor.current) - } else { - MaterialTheme.typography.body1 - }, - cursorColor = AmbientContentColor.current - ) - } -} - -@Composable -private fun CraneBaseUserInput( - modifier: Modifier = Modifier, - caption: String? = null, - @DrawableRes vectorImageId: Int? = null, - showCaption: () -> Boolean = { true }, - tintIcon: () -> Boolean, - tint: Color = AmbientContentColor.current, - content: @Composable () -> Unit -) { - Surface(modifier = modifier, color = MaterialTheme.colors.primaryVariant) { - Row(Modifier.padding(all = 12.dp)) { - if (vectorImageId != null) { - Icon( - modifier = Modifier.preferredSize(24.dp, 24.dp), - imageVector = vectorResource(id = vectorImageId), - tint = if (tintIcon()) tint else Color(0x80FFFFFF) - ) - Spacer(Modifier.preferredWidth(8.dp)) - } - if (caption != null && showCaption()) { - Text( - modifier = Modifier.align(Alignment.CenterVertically), - text = caption, - style = (captionTextStyle).copy(color = tint) - ) - Spacer(Modifier.preferredWidth(8.dp)) - } - Row(Modifier.weight(1f).align(Alignment.CenterVertically)) { - content() - } - } - } -} - -@Preview -@Composable -fun PreviewInput() { - CraneScaffold { - CraneBaseUserInput( - tintIcon = { true }, - vectorImageId = R.drawable.ic_plane, - caption = "Caption", - showCaption = { true } - ) { - Text(text = "text", style = MaterialTheme.typography.body1) - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneDrawer.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneDrawer.kt deleted file mode 100644 index 7cc6e44d87..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneDrawer.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.ui.CraneTheme -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -private val screens = listOf("Find Trips", "My Trips", "Saved Trips", "Price Alerts", "My Account") - -@Composable -fun CraneDrawer(modifier: Modifier = Modifier) { - Column(modifier.fillMaxSize().padding(start = 24.dp, top = 48.dp)) { - Image(imageVector = vectorResource(id = R.drawable.ic_crane_drawer)) - for (screen in screens) { - Spacer(Modifier.preferredHeight(24.dp)) - Text(text = screen, style = MaterialTheme.typography.h4) - } - } -} - -@Preview -@Composable -fun CraneDrawerPreview() { - CraneTheme { - CraneDrawer() - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneTabs.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneTabs.kt deleted file mode 100644 index a993c0ae54..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/CraneTabs.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.ExperimentalLayout -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Tab -import androidx.compose.material.TabRow -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.home.CraneScreen -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.AmbientConfiguration -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import androidx.core.os.ConfigurationCompat - -@Composable -fun CraneTabBar( - modifier: Modifier = Modifier, - onMenuClicked: () -> Unit, - children: @Composable (Modifier) -> Unit -) { - Row(modifier) { - // Separate Row as the children shouldn't have the padding - Row(Modifier.padding(top = 8.dp)) { - Image( - modifier = Modifier.padding(top = 8.dp).clickable(onClick = onMenuClicked), - imageVector = vectorResource(id = R.drawable.ic_menu) - ) - Spacer(Modifier.preferredWidth(8.dp)) - Image(imageVector = vectorResource(id = R.drawable.ic_crane_logo)) - } - children(Modifier.weight(1f).align(Alignment.CenterVertically)) - } -} - -@OptIn(ExperimentalLayout::class) -@Composable -fun CraneTabs( - modifier: Modifier = Modifier, - titles: List<String>, - tabSelected: CraneScreen, - onTabSelected: (CraneScreen) -> Unit -) { - TabRow( - selectedTabIndex = tabSelected.ordinal, - modifier = modifier, - contentColor = MaterialTheme.colors.onSurface, - indicator = { }, - divider = { } - ) { - titles.forEachIndexed { index, title -> - val selected = index == tabSelected.ordinal - - var textModifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) - if (selected) { - textModifier = - Modifier.border(BorderStroke(2.dp, Color.White), RoundedCornerShape(16.dp)) - .then(textModifier) - } - - Tab( - selected = selected, - onClick = { onTabSelected(CraneScreen.values()[index]) } - ) { - Text( - modifier = textModifier, - text = title.toUpperCase( - ConfigurationCompat.getLocales(AmbientConfiguration.current)[0] - ) - ) - } - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/ExploreSection.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/ExploreSection.kt deleted file mode 100644 index 7522815bcd..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/ExploreSection.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.samples.crane.home.OnExploreItemClicked -import androidx.compose.samples.crane.ui.BottomSheetShape -import androidx.compose.samples.crane.ui.crane_caption -import androidx.compose.samples.crane.ui.crane_divider_color -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import dev.chrisbanes.accompanist.coil.CoilImage - -@Composable -fun ExploreSection( - modifier: Modifier = Modifier, - title: String, - exploreList: List<ExploreModel>, - onItemClicked: OnExploreItemClicked -) { - Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) { - Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) { - Text( - text = title, - style = MaterialTheme.typography.caption.copy(color = crane_caption) - ) - Spacer(Modifier.preferredHeight(8.dp)) - LazyColumn( - modifier = Modifier.weight(1f), - ) { - items(exploreList) { exploreItem -> - Column(Modifier.fillParentMaxWidth()) { - ExploreItem( - modifier = Modifier.fillParentMaxWidth(), - item = exploreItem, - onItemClicked = onItemClicked - ) - Divider(color = crane_divider_color) - } - } - } - } - } -} - -@Composable -private fun ExploreItem( - modifier: Modifier = Modifier, - item: ExploreModel, - onItemClicked: OnExploreItemClicked -) { - Row( - modifier = modifier - .clickable { onItemClicked(item) } - .padding(top = 12.dp, bottom = 12.dp) - ) { - ExploreImageContainer { - CoilImage( - data = item.imageUrl, - fadeIn = true, - contentScale = ContentScale.Crop, - loading = { - Box(Modifier.fillMaxSize()) { - Image( - modifier = Modifier.preferredSize(36.dp).align(Alignment.Center), - imageVector = vectorResource(id = R.drawable.ic_crane_logo) - ) - } - } - ) - } - Spacer(Modifier.preferredWidth(24.dp)) - Column { - Text( - text = item.city.nameToDisplay, - style = MaterialTheme.typography.h6 - ) - Spacer(Modifier.preferredHeight(8.dp)) - Text( - text = item.description, - style = MaterialTheme.typography.caption.copy(color = crane_caption) - ) - } - } -} - -@Composable -private fun ExploreImageContainer(content: @Composable () -> Unit) { - Surface(Modifier.preferredSize(width = 60.dp, height = 60.dp), RoundedCornerShape(4.dp)) { - content() - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/Result.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/Result.kt deleted file mode 100644 index 5dad2368e7..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/Result.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -/** - * A generic class that holds a value. - * @param <T> - */ -sealed class Result<out R> { - data class Success<out T>(val data: T) : Result<T>() - data class Error(val exception: Exception) : Result<Nothing>() -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/base/Scaffold.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/base/Scaffold.kt deleted file mode 100644 index 33087fc1f3..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/base/Scaffold.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.base - -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.ui.CraneTheme - -@Composable -fun CraneScaffold(content: @Composable () -> Unit) { - CraneTheme { - Surface(color = MaterialTheme.colors.primary) { - content() - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/Calendar.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/Calendar.kt deleted file mode 100644 index b049a7b383..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/Calendar.kt +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar - -import androidx.compose.foundation.ScrollableColumn -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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredHeightIn -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.calendar.model.CalendarDay -import androidx.compose.samples.crane.calendar.model.CalendarMonth -import androidx.compose.samples.crane.calendar.model.DayOfWeek -import androidx.compose.samples.crane.calendar.model.DaySelectedStatus -import androidx.compose.samples.crane.data.CalendarYear -import androidx.compose.samples.crane.data.DatesLocalDataSource -import androidx.compose.samples.crane.ui.CraneTheme -import androidx.compose.samples.crane.util.Circle -import androidx.compose.samples.crane.util.SemiRect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.SemanticsPropertyKey -import androidx.compose.ui.semantics.SemanticsPropertyReceiver -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -typealias CalendarWeek = List<CalendarDay> - -@Composable -fun Calendar( - calendarYear: CalendarYear, - onDayClicked: (CalendarDay, CalendarMonth) -> Unit, - modifier: Modifier = Modifier -) { - ScrollableColumn(modifier = modifier) { - Spacer(Modifier.preferredHeight(32.dp)) - for (month in calendarYear) { - Month(month = month, onDayClicked = onDayClicked) - Spacer(Modifier.preferredHeight(32.dp)) - } - } -} - -@Composable -private fun Month( - modifier: Modifier = Modifier, - month: CalendarMonth, - onDayClicked: (CalendarDay, CalendarMonth) -> Unit -) { - Column(modifier = modifier) { - MonthHeader( - modifier = Modifier.padding(horizontal = 30.dp), - month = month.name, - year = month.year - ) - - // Expanding width and centering horizontally - val contentModifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally) - DaysOfWeek(modifier = contentModifier) - for (week in month.weeks.value) { - Week( - modifier = contentModifier, - week = week, - month = month, - onDayClicked = { day -> - onDayClicked(day, month) - } - ) - Spacer(Modifier.preferredHeight(8.dp)) - } - } -} - -@Composable -private fun MonthHeader(modifier: Modifier = Modifier, month: String, year: String) { - Row(modifier = modifier) { - Text( - modifier = Modifier.weight(1f), - text = month, - style = MaterialTheme.typography.h6 - ) - Text( - modifier = Modifier.align(Alignment.CenterVertically), - text = year, - style = MaterialTheme.typography.caption - ) - } -} - -@Composable -private fun Week( - modifier: Modifier = Modifier, - month: CalendarMonth, - week: CalendarWeek, - onDayClicked: (CalendarDay) -> Unit -) { - val (leftFillColor, rightFillColor) = getLeftRightWeekColors(week, month) - - Row(modifier = modifier) { - val spaceModifiers = Modifier.weight(1f).preferredHeightIn(max = CELL_SIZE) - Surface(modifier = spaceModifiers, color = leftFillColor) { - Spacer(Modifier.fillMaxHeight()) - } - for (day in week) { - Day( - day, - onDayClicked, - Modifier.semantics { - contentDescription = "${month.name} ${day.value}" - dayStatusProperty = day.status - } - ) - } - Surface(modifier = spaceModifiers, color = rightFillColor) { - Spacer(Modifier.fillMaxHeight()) - } - } -} - -@Composable -private fun DaysOfWeek(modifier: Modifier = Modifier) { - Row(modifier = modifier) { - for (day in DayOfWeek.values()) { - Day(name = day.name.take(1)) - } - } -} - -@Composable -private fun Day( - day: CalendarDay, - onDayClicked: (CalendarDay) -> Unit, - modifier: Modifier = Modifier -) { - val enabled = day.status != DaySelectedStatus.NonClickable - DayContainer( - modifier = modifier.clickable(enabled) { - if (day.status != DaySelectedStatus.NonClickable) onDayClicked(day) - }, - backgroundColor = day.status.color(MaterialTheme.colors) - ) { - DayStatusContainer(status = day.status) { - Text( - modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center), - text = day.value, - style = MaterialTheme.typography.body1.copy(color = Color.White) - ) - } - } -} - -@Composable -private fun Day(name: String) { - DayContainer { - Text( - modifier = Modifier.wrapContentSize(Alignment.Center), - text = name, - style = MaterialTheme.typography.caption.copy(Color.White.copy(alpha = 0.6f)) - ) - } -} - -@Composable -private fun DayContainer( - modifier: Modifier = Modifier, - backgroundColor: Color = Color.Transparent, - content: @Composable () -> Unit -) { - // What if this doesn't fit the screen? - LayoutFlexible(1f) + LayoutAspectRatio(1f) - Surface( - modifier = modifier.preferredSize(width = CELL_SIZE, height = CELL_SIZE), - color = backgroundColor - ) { - content() - } -} - -@Composable -private fun DayStatusContainer( - status: DaySelectedStatus, - content: @Composable () -> Unit -) { - if (status.isMarked()) { - Box { - val color = MaterialTheme.colors.secondary - Circle(color = color) - if (status == DaySelectedStatus.FirstDay) { - SemiRect(color = color, lookingLeft = false) - } else if (status == DaySelectedStatus.LastDay) { - SemiRect(color = color, lookingLeft = true) - } - content() - } - } else { - content() - } -} - -private fun DaySelectedStatus.color(theme: Colors): Color = when (this) { - DaySelectedStatus.Selected -> theme.secondary - else -> Color.Transparent -} - -@Composable -private fun getLeftRightWeekColors(week: CalendarWeek, month: CalendarMonth): Pair<Color, Color> { - val materialColors = MaterialTheme.colors - - val firstDayOfTheWeek = week[0].value - val leftFillColor = if (firstDayOfTheWeek.isNotEmpty()) { - val lastDayPreviousWeek = month.getPreviousDay(firstDayOfTheWeek.toInt()) - if (lastDayPreviousWeek?.status?.isMarked() == true && week[0].status.isMarked()) { - materialColors.secondary - } else { - Color.Transparent - } - } else { - Color.Transparent - } - - val lastDayOfTheWeek = week[6].value - val rightFillColor = if (lastDayOfTheWeek.isNotEmpty()) { - val firstDayNextWeek = month.getNextDay(lastDayOfTheWeek.toInt()) - if (firstDayNextWeek?.status?.isMarked() == true && week[6].status.isMarked()) { - materialColors.secondary - } else { - Color.Transparent - } - } else { - Color.Transparent - } - - return leftFillColor to rightFillColor -} - -private fun DaySelectedStatus.isMarked(): Boolean { - return when (this) { - DaySelectedStatus.Selected -> true - DaySelectedStatus.FirstDay -> true - DaySelectedStatus.LastDay -> true - DaySelectedStatus.FirstLastDay -> true - else -> false - } -} - -private val CELL_SIZE = 48.dp - -val DayStatusKey = SemanticsPropertyKey<DaySelectedStatus>("DayStatusKey") -var SemanticsPropertyReceiver.dayStatusProperty by DayStatusKey - -@Preview -@Composable -fun DayPreview() { - CraneTheme { - Calendar(DatesLocalDataSource().year2020, onDayClicked = { _, _ -> }) - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarActivity.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarActivity.kt deleted file mode 100644 index 47b164b416..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarActivity.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.base.CraneScaffold -import androidx.compose.samples.crane.calendar.model.CalendarDay -import androidx.compose.samples.crane.calendar.model.CalendarMonth -import androidx.compose.samples.crane.calendar.model.DaySelected -import androidx.compose.samples.crane.data.CalendarYear -import androidx.compose.ui.platform.setContent -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.viewinterop.viewModel -import dagger.hilt.android.AndroidEntryPoint - -fun launchCalendarActivity(context: Context) { - val intent = Intent(context, CalendarActivity::class.java) - context.startActivity(intent) -} - -@AndroidEntryPoint -class CalendarActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - CraneScaffold { - Surface { - CalendarScreen(onBackPressed = { finish() }) - } - } - } - } -} - -@Composable -fun CalendarScreen(onBackPressed: () -> Unit) { - val calendarViewModel: CalendarViewModel = viewModel() - val calendarYear = calendarViewModel.calendarYear - - CalendarContent( - selectedDates = calendarViewModel.datesSelected.toString(), - calendarYear = calendarYear, - onDayClicked = { calendarDay, calendarMonth -> - calendarViewModel.onDaySelected( - DaySelected(calendarDay.value.toInt(), calendarMonth, calendarYear) - ) - }, - onBackPressed = onBackPressed - ) -} - -@Composable -private fun CalendarContent( - selectedDates: String, - calendarYear: CalendarYear, - onDayClicked: (CalendarDay, CalendarMonth) -> Unit, - onBackPressed: () -> Unit -) { - CraneScaffold { - Column { - TopAppBar( - title = { - Text( - text = if (selectedDates.isEmpty()) "Select Dates" - else selectedDates - ) - }, - navigationIcon = { - IconButton(onClick = { onBackPressed() }) { - Image(imageVector = vectorResource(id = R.drawable.ic_back)) - } - }, - backgroundColor = MaterialTheme.colors.primaryVariant - ) - Surface { - Calendar(calendarYear, onDayClicked) - } - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarViewModel.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarViewModel.kt deleted file mode 100644 index 1cee9585b6..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/CalendarViewModel.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar - -import androidx.compose.samples.crane.calendar.model.DaySelected -import androidx.compose.samples.crane.data.DatesRepository -import androidx.hilt.lifecycle.ViewModelInject -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch - -class CalendarViewModel @ViewModelInject constructor( - private val datesRepository: DatesRepository -) : ViewModel() { - - val datesSelected = datesRepository.datesSelected - val calendarYear = datesRepository.calendarYear - - fun onDaySelected(daySelected: DaySelected) { - viewModelScope.launch { - datesRepository.onDaySelected(daySelected) - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarDay.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarDay.kt deleted file mode 100644 index 8e1edb3d8b..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarDay.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar.model - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -enum class DayOfWeek { - Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday -} - -enum class DaySelectedStatus { - NoSelected, Selected, NonClickable, FirstDay, LastDay, FirstLastDay -} - -class CalendarDay(val value: String, status: DaySelectedStatus) { - var status by mutableStateOf(status) -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarMonth.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarMonth.kt deleted file mode 100644 index 19d0d3f8f9..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/CalendarMonth.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar.model - -data class CalendarMonth( - val name: String, - val year: String, - val numDays: Int, - val monthNumber: Int, - val startDayOfWeek: DayOfWeek -) { - private val days = mutableListOf<CalendarDay>().apply { - // Add offset of the start of the month - for (i in 1..startDayOfWeek.ordinal) { - add( - CalendarDay( - "", - DaySelectedStatus.NonClickable - ) - ) - } - // Add days of the month - for (i in 1..numDays) { - add( - CalendarDay( - i.toString(), - DaySelectedStatus.NoSelected - ) - ) - } - }.toList() - - fun getDay(day: Int): CalendarDay { - return days[day + startDayOfWeek.ordinal - 1] - } - - fun getPreviousDay(day: Int): CalendarDay? { - if (day <= 1) return null - return getDay(day - 1) - } - - fun getNextDay(day: Int): CalendarDay? { - if (day >= numDays) return null - return getDay(day + 1) - } - - val weeks = lazy { days.chunked(7).map { completeWeek(it) } } - - private fun completeWeek(list: List<CalendarDay>): List<CalendarDay> { - var gapsToFill = 7 - list.size - - return if (gapsToFill != 0) { - val mutableList = list.toMutableList() - while (gapsToFill > 0) { - mutableList.add( - CalendarDay( - "", - DaySelectedStatus.NonClickable - ) - ) - gapsToFill-- - } - mutableList - } else { - list - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DatesSelectedState.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DatesSelectedState.kt deleted file mode 100644 index 8d4cfc64dd..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DatesSelectedState.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar.model - -import androidx.annotation.VisibleForTesting -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.data.CalendarYear - -class DatesSelectedState(private val year: CalendarYear) { - private var from by mutableStateOf(DaySelectedEmpty) - private var to by mutableStateOf(DaySelectedEmpty) - - override fun toString(): String { - if (from == DaySelectedEmpty && to == DaySelectedEmpty) return "" - var output = from.toString() - if (to != DaySelectedEmpty) { - output += " - $to" - } - return output - } - - fun daySelected(newDate: DaySelected) { - if (from == DaySelectedEmpty && to == DaySelectedEmpty) { - setDates(newDate, DaySelectedEmpty) - } else if (from != DaySelectedEmpty && to != DaySelectedEmpty) { - clearDates() - daySelected(newDate = newDate) - } else if (from == DaySelectedEmpty) { - if (newDate < to) setDates(newDate, to) - else if (newDate > to) setDates(to, newDate) - } else if (to == DaySelectedEmpty) { - if (newDate < from) setDates(newDate, from) - else if (newDate > from) setDates(from, newDate) - } - } - - private fun setDates(newFrom: DaySelected, newTo: DaySelected) { - if (newTo == DaySelectedEmpty) { - from = newFrom - from.calendarDay.value.status = DaySelectedStatus.FirstLastDay - } else { - from = newFrom.apply { calendarDay.value.status = DaySelectedStatus.FirstDay } - selectDatesInBetween(newFrom, newTo) - to = newTo.apply { calendarDay.value.status = DaySelectedStatus.LastDay } - } - } - - private fun selectDatesInBetween(from: DaySelected, to: DaySelected) { - if (from.month == to.month) { - for (i in (from.day + 1) until to.day) - from.month.getDay(i).status = DaySelectedStatus.Selected - } else { - // Fill from's month - for (i in (from.day + 1) until from.month.numDays) { - from.month.getDay(i).status = DaySelectedStatus.Selected - } - from.month.getDay(from.month.numDays).status = DaySelectedStatus.LastDay - // Fill in-between months - for (i in (from.month.monthNumber + 1) until to.month.monthNumber) { - val month = year[i - 1] - month.getDay(1).status = DaySelectedStatus.FirstDay - for (j in 2 until month.numDays) { - month.getDay(j).status = DaySelectedStatus.Selected - } - month.getDay(month.numDays).status = DaySelectedStatus.LastDay - } - // Fill to's month - to.month.getDay(1).status = DaySelectedStatus.FirstDay - for (i in 2 until to.day) { - to.month.getDay(i).status = DaySelectedStatus.Selected - } - } - } - - @VisibleForTesting - fun clearDates() { - if (from != DaySelectedEmpty || to != DaySelectedEmpty) { - // Unselect dates from the same month - if (from.month == to.month) { - for (i in from.day..to.day) - from.month.getDay(i).status = DaySelectedStatus.NoSelected - } else { - // Unselect from's month - for (i in from.day..from.month.numDays) { - from.month.getDay(i).status = DaySelectedStatus.NoSelected - } - // Fill in-between months - for (i in (from.month.monthNumber + 1) until to.month.monthNumber) { - val month = year[i - 1] - for (j in 1..month.numDays) { - month.getDay(j).status = DaySelectedStatus.NoSelected - } - } - // Fill to's month - for (i in 1..to.day) { - to.month.getDay(i).status = DaySelectedStatus.NoSelected - } - } - } - from.calendarDay.value.status = DaySelectedStatus.NoSelected - from = DaySelectedEmpty - to.calendarDay.value.status = DaySelectedStatus.NoSelected - to = DaySelectedEmpty - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DaySelected.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DaySelected.kt deleted file mode 100644 index 4dfd0a287c..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/calendar/model/DaySelected.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.calendar.model - -import androidx.compose.samples.crane.data.CalendarYear - -data class DaySelected(val day: Int, val month: CalendarMonth, val year: CalendarYear) { - val calendarDay = lazy { - month.getDay(day) - } - - override fun toString(): String { - return "${month.name.substring(0, 3).capitalize()} $day" - } - - operator fun compareTo(other: DaySelected): Int { - if (day == other.day && month == other.month) return 0 - if (month == other.month) return day.compareTo(other.day) - return (year.indexOf(month)).compareTo( - year.indexOf(other.month) - ) - } -} - -/** - * Represents an empty value for [DaySelected] - */ -val DaySelectedEmpty = DaySelected(-1, CalendarMonth("", "", 0, 0, DayOfWeek.Sunday), emptyList()) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/Cities.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/Cities.kt deleted file mode 100644 index 9ae8efad83..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/Cities.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -val MADRID = City( - name = "Madrid", - country = "Spain", - latitude = "40.416775", - longitude = "-3.703790" -) - -val NAPLES = City( - name = "Naples", - country = "Italy", - latitude = "40.853294", - longitude = "14.305573" -) - -val DALLAS = City( - name = "Dallas", - country = "US", - latitude = "32.779167", - longitude = "-96.808891" -) - -val CORDOBA = City( - name = "Cordoba", - country = "Argentina", - latitude = "-31.416668", - longitude = "-64.183334" -) - -val MALDIVAS = City( - name = "Maldivas", - country = "South Asia", - latitude = "1.924992", - longitude = "73.399658" -) - -val ASPEN = City( - name = "Aspen", - country = "Colorado", - latitude = "39.191097", - longitude = "-106.817535" -) - -val BALI = City( - name = "Bali", - country = "Indonesia", - latitude = "-8.3405", - longitude = "115.0920" -) - -val BIGSUR = City( - name = "Big Sur", - country = "California", - latitude = "36.2704", - longitude = "-121.8081" -) - -val KHUMBUVALLEY = City( - name = "Khumbu Valley", - country = "Nepal", - latitude = "27.9320", - longitude = "86.8050" -) - -val ROME = City( - name = "Rome", - country = "Italy", - latitude = "41.902782", - longitude = "12.496366" -) - -val GRANADA = City( - name = "Granada", - country = "Spain", - latitude = "37.18817", - longitude = "-3.60667" -) - -val WASHINGTONDC = City( - name = "Washington DC", - country = "USA", - latitude = "38.9072", - longitude = "-77.0369" -) - -val BARCELONA = City( - name = "Barcelona", - country = "Spain", - latitude = "41.390205", - longitude = "2.154007" -) - -val CRETE = City( - name = "Crete", - country = "Greece", - latitude = "35.2401", - longitude = "24.8093" -) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesLocalDataSource.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesLocalDataSource.kt deleted file mode 100644 index d33dd24959..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesLocalDataSource.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import androidx.compose.samples.crane.calendar.model.CalendarDay -import androidx.compose.samples.crane.calendar.model.CalendarMonth -import androidx.compose.samples.crane.calendar.model.DayOfWeek -import javax.inject.Inject -import javax.inject.Singleton - -typealias CalendarYear = List<CalendarMonth> - -/** - * Annotated with Singleton because [CalendarDay] contains mutable state. - */ -@Singleton -class DatesLocalDataSource @Inject constructor() { - - private val january2020 = CalendarMonth( - name = "January", - year = "2020", - numDays = 31, - monthNumber = 1, - startDayOfWeek = DayOfWeek.Wednesday - ) - private val february2020 = CalendarMonth( - name = "February", - year = "2020", - numDays = 29, - monthNumber = 2, - startDayOfWeek = DayOfWeek.Saturday - ) - private val march2020 = CalendarMonth( - name = "March", - year = "2020", - numDays = 31, - monthNumber = 3, - startDayOfWeek = DayOfWeek.Sunday - ) - private val april2020 = CalendarMonth( - name = "April", - year = "2020", - numDays = 30, - monthNumber = 4, - startDayOfWeek = DayOfWeek.Wednesday - ) - private val may2020 = CalendarMonth( - name = "May", - year = "2020", - numDays = 31, - monthNumber = 5, - startDayOfWeek = DayOfWeek.Friday - ) - private val june2020 = CalendarMonth( - name = "June", - year = "2020", - numDays = 30, - monthNumber = 6, - startDayOfWeek = DayOfWeek.Monday - ) - private val july2020 = CalendarMonth( - name = "July", - year = "2020", - numDays = 31, - monthNumber = 7, - startDayOfWeek = DayOfWeek.Wednesday - ) - private val august2020 = CalendarMonth( - name = "August", - year = "2020", - numDays = 31, - monthNumber = 8, - startDayOfWeek = DayOfWeek.Saturday - ) - private val september2020 = CalendarMonth( - name = "September", - year = "2020", - numDays = 30, - monthNumber = 9, - startDayOfWeek = DayOfWeek.Tuesday - ) - private val october2020 = CalendarMonth( - name = "October", - year = "2020", - numDays = 31, - monthNumber = 10, - startDayOfWeek = DayOfWeek.Thursday - ) - private val november2020 = CalendarMonth( - name = "November", - year = "2020", - numDays = 30, - monthNumber = 11, - startDayOfWeek = DayOfWeek.Sunday - ) - private val december2020 = CalendarMonth( - name = "December", - year = "2020", - numDays = 31, - monthNumber = 12, - startDayOfWeek = DayOfWeek.Tuesday - ) - - val year2020: CalendarYear = listOf( - january2020, - february2020, - march2020, - april2020, - may2020, - june2020, - july2020, - august2020, - september2020, - october2020, - november2020, - december2020 - ) -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesRepository.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesRepository.kt deleted file mode 100644 index 7b0dc0e7c5..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DatesRepository.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import androidx.compose.samples.crane.calendar.model.DatesSelectedState -import androidx.compose.samples.crane.calendar.model.DaySelected -import androidx.compose.samples.crane.di.DefaultDispatcher -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Annotated with Singleton because [DatesSelectedState] contains mutable state. - */ -@Singleton -class DatesRepository @Inject constructor( - datesLocalDataSource: DatesLocalDataSource, - @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, - -) { - val calendarYear = datesLocalDataSource.year2020 - val datesSelected = DatesSelectedState(datesLocalDataSource.year2020) - - suspend fun onDaySelected(daySelected: DaySelected) = withContext(defaultDispatcher) { - datesSelected.daySelected(daySelected) - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsLocalDataSource.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsLocalDataSource.kt deleted file mode 100644 index bb5d179182..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsLocalDataSource.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import javax.inject.Inject -import javax.inject.Singleton - -private const val DEFAULT_IMAGE_WIDTH = "250" - -/** - * Annotated with Singleton as the class created a lot of objects. - */ -@Singleton -class DestinationsLocalDataSource @Inject constructor() { - - val craneRestaurants = listOf( - ExploreModel( - city = NAPLES, - description = "1286 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1534308983496-4fabb1a015ee?ixlib=rb-1.2.1&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = DALLAS, - description = "2241 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1495749388945-9d6e4e5b67b1?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = CORDOBA, - description = "876 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1562625964-ffe9b2f617fc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=250&q=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = MADRID, - description = "5610 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1515443961218-a51367888e4b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = MALDIVAS, - description = "1286 Restaurants", - imageUrl = "https://images.unsplash.com/flagged/photo-1556202256-af2687079e51?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = ASPEN, - description = "2241 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1542384557-0824d90731ee?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BALI, - description = "876 Restaurants", - imageUrl = "https://images.unsplash.com/photo-1567337710282-00832b415979?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ) - ) - - val craneHotels = listOf( - ExploreModel( - city = MALDIVAS, - description = "1286 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = ASPEN, - description = "2241 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1445019980597-93fa8acb246c?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BALI, - description = "876 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1570213489059-0aac6626cade?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BIGSUR, - description = "5610 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1561409037-c7be81613c1f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = NAPLES, - description = "1286 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1455587734955-081b22074882?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = DALLAS, - description = "2241 Available Properties", - imageUrl = "https://images.unsplash.com/46/sh3y2u5PSaKq8c4LxB3B_submission-photo-4.jpg?ixlib=rb-1.2.1&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = CORDOBA, - description = "876 Available Properties", - imageUrl = "https://images.unsplash.com/photo-1570214476695-19bd467e6f7a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ) - ) - - val craneDestinations = listOf( - ExploreModel( - city = KHUMBUVALLEY, - description = "Nonstop - 5h 16m+", - imageUrl = "https://images.unsplash.com/photo-1544735716-392fe2489ffa?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = MADRID, - description = "Nonstop - 2h 12m+", - imageUrl = "https://images.unsplash.com/photo-1539037116277-4db20889f2d4?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BALI, - description = "Nonstop - 6h 20m+", - imageUrl = "https://images.unsplash.com/photo-1518548419970-58e3b4079ab2?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = ROME, - description = "Nonstop - 2h 38m+", - imageUrl = "https://images.unsplash.com/photo-1515542622106-78bda8ba0e5b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = GRANADA, - description = "Nonstop - 2h 12m+", - imageUrl = "https://images.unsplash.com/photo-1534423839368-1796a4dd1845?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = MALDIVAS, - description = "Nonstop - 9h 24m+", - imageUrl = "https://images.unsplash.com/photo-1544550581-5f7ceaf7f992?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = WASHINGTONDC, - description = "Nonstop - 7h 30m+", - imageUrl = "https://images.unsplash.com/photo-1557160854-e1e89fdd3286?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = BARCELONA, - description = "Nonstop - 2h 12m+", - imageUrl = "https://images.unsplash.com/photo-1562883676-8c7feb83f09b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ), - ExploreModel( - city = CRETE, - description = "Nonstop - 1h 50m+", - imageUrl = "https://images.unsplash.com/photo-1486575008575-27670acb58db?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=$DEFAULT_IMAGE_WIDTH" - ) - ) -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsRepository.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsRepository.kt deleted file mode 100644 index df419033a4..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/DestinationsRepository.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import javax.inject.Inject - -class DestinationsRepository @Inject constructor( - private val destinationsLocalDataSource: DestinationsLocalDataSource -) { - val destinations: List<ExploreModel> = destinationsLocalDataSource.craneDestinations - val hotels: List<ExploreModel> = destinationsLocalDataSource.craneHotels - val restaurants: List<ExploreModel> = destinationsLocalDataSource.craneRestaurants - - fun getDestination(cityName: String): ExploreModel? { - return destinationsLocalDataSource.craneDestinations.firstOrNull { - it.city.name == cityName - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/data/ExploreModel.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/data/ExploreModel.kt deleted file mode 100644 index 9155e0055f..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/data/ExploreModel.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.data - -import androidx.compose.runtime.Immutable - -@Immutable -data class City( - val name: String, - val country: String, - val latitude: String, - val longitude: String -) { - val nameToDisplay = "$name, $country" -} - -@Immutable -data class ExploreModel( - val city: City, - val description: String, - val imageUrl: String -) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsActivity.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsActivity.kt deleted file mode 100644 index 05b2c8acd4..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsActivity.kt +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.details - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.savedinstancestate.savedInstanceState -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.base.CraneScaffold -import androidx.compose.samples.crane.base.Result -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.setContent -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import com.google.android.libraries.maps.CameraUpdateFactory -import com.google.android.libraries.maps.MapView -import com.google.android.libraries.maps.model.LatLng -import com.google.maps.android.ktx.addMarker -import com.google.maps.android.ktx.awaitMap -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import javax.inject.Inject - -private const val KEY_ARG_DETAILS_CITY_NAME = "KEY_ARG_DETAILS_CITY_NAME" - -fun launchDetailsActivity(context: Context, item: ExploreModel) { - context.startActivity(createDetailsActivityIntent(context, item)) -} - -@VisibleForTesting -fun createDetailsActivityIntent(context: Context, item: ExploreModel): Intent { - val intent = Intent(context, DetailsActivity::class.java) - intent.putExtra(KEY_ARG_DETAILS_CITY_NAME, item.city.name) - return intent -} - -data class DetailsActivityArg( - val cityName: String -) - -@AndroidEntryPoint -class DetailsActivity : ComponentActivity() { - - @Inject - lateinit var viewModelFactory: DetailsViewModel.AssistedFactory - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val args = getDetailsArgs(intent) - - setContent { - CraneScaffold { - DetailsScreen(args, viewModelFactory, onErrorLoading = { finish() }) - } - } - } - - private fun getDetailsArgs(intent: Intent): DetailsActivityArg { - val cityArg = intent.getStringExtra(KEY_ARG_DETAILS_CITY_NAME) - if (cityArg.isNullOrEmpty()) { - throw IllegalStateException("DETAILS_CITY_NAME arg cannot be null or empty") - } - return DetailsActivityArg(cityArg) - } -} - -@Composable -fun DetailsScreen( - args: DetailsActivityArg, - viewModelFactory: DetailsViewModel.AssistedFactory, - onErrorLoading: () -> Unit -) { - val viewModel: DetailsViewModel = viewModelFactory.create(args.cityName) - - val cityDetailsResult = remember(viewModel) { viewModel.cityDetails } - if (cityDetailsResult is Result.Success<ExploreModel>) { - DetailsContent(cityDetailsResult.data) - } else { - onErrorLoading() - } -} - -@Composable -fun DetailsContent(exploreModel: ExploreModel) { - Column(verticalArrangement = Arrangement.Center) { - Spacer(Modifier.preferredHeight(32.dp)) - Text( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = exploreModel.city.nameToDisplay, - style = MaterialTheme.typography.h4 - ) - Text( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = exploreModel.description, - style = MaterialTheme.typography.h6 - ) - Spacer(Modifier.preferredHeight(16.dp)) - CityMapView(exploreModel.city.latitude, exploreModel.city.longitude) - } -} - -@Composable -private fun CityMapView(latitude: String, longitude: String) { - // The MapView lifecycle is handled by this composable. As the MapView also needs to be updated - // with input from Compose UI, those updates are encapsulated into the MapViewContainer - // composable. In this way, when an update to the MapView happens, this composable won't - // recompose and the MapView won't need to be recreated. - val mapView = rememberMapViewWithLifecycle() - MapViewContainer(mapView, latitude, longitude) -} - -@Composable -private fun MapViewContainer( - map: MapView, - latitude: String, - longitude: String -) { - var zoom by savedInstanceState { InitialZoom } - val coroutineScope = rememberCoroutineScope() - - ZoomControls(zoom) { - zoom = it.coerceIn(MinZoom, MaxZoom) - } - AndroidView({ map }) { mapView -> - // Reading zoom so that AndroidView recomposes when it changes. The getMapAsync lambda - // is stored for later, Compose doesn't recognize state reads - val mapZoom = zoom - coroutineScope.launch { - val googleMap = mapView.awaitMap() - googleMap.setZoom(mapZoom) - val position = LatLng(latitude.toDouble(), longitude.toDouble()) - googleMap.addMarker { - position(position) - } - googleMap.moveCamera(CameraUpdateFactory.newLatLng(position)) - } - } -} - -@Composable -private fun ZoomControls( - zoom: Float, - onZoomChanged: (Float) -> Unit -) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - ZoomButton("-", onClick = { onZoomChanged(zoom * 0.8f) }) - ZoomButton("+", onClick = { onZoomChanged(zoom * 1.2f) }) - } -} - -@Composable -private fun ZoomButton(text: String, onClick: () -> Unit) { - Button( - modifier = Modifier.padding(8.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.onPrimary, - contentColor = MaterialTheme.colors.primary - ), - onClick = onClick - ) { - Text(text = text, style = MaterialTheme.typography.h5) - } -} - -private const val InitialZoom = 5f -const val MinZoom = 2f -const val MaxZoom = 20f diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsViewModel.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsViewModel.kt deleted file mode 100644 index c97ffe1407..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/details/DetailsViewModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.details - -import androidx.compose.samples.crane.base.Result -import androidx.compose.samples.crane.data.DestinationsRepository -import androidx.compose.samples.crane.data.ExploreModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.squareup.inject.assisted.Assisted -import com.squareup.inject.assisted.AssistedInject -import com.squareup.inject.assisted.dagger2.AssistedModule -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityRetainedComponent -import java.lang.IllegalArgumentException - -class DetailsViewModel @AssistedInject constructor( - private val destinationsRepository: DestinationsRepository, - @Assisted private val cityName: String -) : ViewModel() { - - val cityDetails: Result<ExploreModel> - get() { - val destination = destinationsRepository.getDestination(cityName) - return if (destination != null) { - Result.Success(destination) - } else { - Result.Error(IllegalArgumentException("City doesn't exist")) - } - } - - @AssistedInject.Factory - interface AssistedFactory { - fun create(cityName: String): DetailsViewModel - } - - @Suppress("UNCHECKED_CAST") - companion object { - fun provideFactory( - assistedFactory: AssistedFactory, - cityName: String - ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { - override fun <T : ViewModel?> create(modelClass: Class<T>): T { - return assistedFactory.create(cityName) as T - } - } - } -} - -@InstallIn(ActivityRetainedComponent::class) -@AssistedModule -@Module(includes = [AssistedInject_AssistedInjectModule::class]) -interface AssistedInjectModule diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/details/MapViewUtils.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/details/MapViewUtils.kt deleted file mode 100644 index 90c76a1caf..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/details/MapViewUtils.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.details - -import android.os.Bundle -import androidx.annotation.FloatRange -import androidx.compose.runtime.Composable -import androidx.compose.runtime.onCommit -import androidx.compose.runtime.remember -import androidx.compose.samples.crane.R -import androidx.compose.ui.platform.AmbientContext -import androidx.compose.ui.platform.AmbientLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import com.google.android.libraries.maps.GoogleMap -import com.google.android.libraries.maps.MapView - -/** - * Remembers a MapView and gives it the lifecycle of the current LifecycleOwner - */ -@Composable -fun rememberMapViewWithLifecycle(): MapView { - val context = AmbientContext.current - val mapView = remember { - MapView(context).apply { - id = R.id.map - } - } - - // Makes MapView follow the lifecycle of this composable - val lifecycleObserver = rememberMapLifecycleObserver(mapView) - val lifecycle = AmbientLifecycleOwner.current.lifecycle - onCommit(lifecycle) { - lifecycle.addObserver(lifecycleObserver) - onDispose { - lifecycle.removeObserver(lifecycleObserver) - } - } - - return mapView -} - -@Composable -private fun rememberMapLifecycleObserver(mapView: MapView): LifecycleEventObserver = - remember(mapView) { - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle()) - Lifecycle.Event.ON_START -> mapView.onStart() - Lifecycle.Event.ON_RESUME -> mapView.onResume() - Lifecycle.Event.ON_PAUSE -> mapView.onPause() - Lifecycle.Event.ON_STOP -> mapView.onStop() - Lifecycle.Event.ON_DESTROY -> mapView.onDestroy() - else -> throw IllegalStateException() - } - } - } - -fun GoogleMap.setZoom( - @FloatRange(from = MinZoom.toDouble(), to = MaxZoom.toDouble()) zoom: Float -) { - resetMinMaxZoomPreference() - setMinZoomPreference(zoom) - setMaxZoomPreference(zoom) -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/di/DispatchersModule.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/di/DispatchersModule.kt deleted file mode 100644 index 21ec6554f0..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/di/DispatchersModule.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.di - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import javax.inject.Qualifier - -@Module -@InstallIn(SingletonComponent::class) -class DispatchersModule { - - @Provides - @DefaultDispatcher - fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default -} - -@Retention(AnnotationRetention.BINARY) -@Qualifier -annotation class DefaultDispatcher diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/CraneHome.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/CraneHome.kt deleted file mode 100644 index 570e36cc2c..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/CraneHome.kt +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.material.BackdropScaffold -import androidx.compose.material.BackdropValue -import androidx.compose.material.DrawerValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalDrawerLayout -import androidx.compose.material.rememberBackdropScaffoldState -import androidx.compose.material.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.base.CraneDrawer -import androidx.compose.samples.crane.base.CraneTabBar -import androidx.compose.samples.crane.base.CraneTabs -import androidx.compose.samples.crane.base.ExploreSection -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.viewinterop.viewModel - -typealias OnExploreItemClicked = (ExploreModel) -> Unit - -enum class CraneScreen { - Fly, Sleep, Eat -} - -@Composable -fun CraneHome( - onExploreItemClicked: OnExploreItemClicked, - onDateSelectionClicked: () -> Unit, - modifier: Modifier = Modifier, -) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - ModalDrawerLayout( - drawerState = drawerState, - gesturesEnabled = drawerState.isOpen, - drawerContent = { CraneDrawer() }, - bodyContent = { - CraneHomeContent( - modifier = modifier, - onExploreItemClicked = onExploreItemClicked, - onDateSelectionClicked = onDateSelectionClicked, - openDrawer = { drawerState.open() } - ) - } - ) -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun CraneHomeContent( - onExploreItemClicked: OnExploreItemClicked, - onDateSelectionClicked: () -> Unit, - openDrawer: () -> Unit, - modifier: Modifier = Modifier, -) { - val viewModel: MainViewModel = viewModel() - val suggestedDestinations by viewModel.suggestedDestinations.observeAsState() - - val onPeopleChanged: (Int) -> Unit = { viewModel.updatePeople(it) } - var tabSelected by remember { mutableStateOf(CraneScreen.Fly) } - - BackdropScaffold( - modifier = modifier, - scaffoldState = rememberBackdropScaffoldState(BackdropValue.Revealed), - frontLayerScrimColor = Color.Transparent, - appBar = { - HomeTabBar(openDrawer, tabSelected, onTabSelected = { tabSelected = it }) - }, - backLayerContent = { - SearchContent( - tabSelected, - viewModel, - onPeopleChanged, - onDateSelectionClicked, - onExploreItemClicked - ) - }, - frontLayerContent = { - when (tabSelected) { - CraneScreen.Fly -> { - suggestedDestinations?.let { destinations -> - ExploreSection( - title = "Explore Flights by Destination", - exploreList = destinations, - onItemClicked = onExploreItemClicked - ) - } - } - CraneScreen.Sleep -> { - ExploreSection( - title = "Explore Properties by Destination", - exploreList = viewModel.hotels, - onItemClicked = onExploreItemClicked - ) - } - CraneScreen.Eat -> { - ExploreSection( - title = "Explore Restaurants by Destination", - exploreList = viewModel.restaurants, - onItemClicked = onExploreItemClicked - ) - } - } - } - ) -} - -@Composable -private fun HomeTabBar( - openDrawer: () -> Unit, - tabSelected: CraneScreen, - onTabSelected: (CraneScreen) -> Unit, - modifier: Modifier = Modifier -) { - CraneTabBar( - modifier = modifier, - onMenuClicked = openDrawer - ) { tabBarModifier -> - CraneTabs( - modifier = tabBarModifier, - titles = CraneScreen.values().map { it.name }, - tabSelected = tabSelected, - onTabSelected = { newTab -> onTabSelected(CraneScreen.values()[newTab.ordinal]) } - ) - } -} - -@Composable -private fun SearchContent( - tabSelected: CraneScreen, - viewModel: MainViewModel, - onPeopleChanged: (Int) -> Unit, - onDateSelectionClicked: () -> Unit, - onExploreItemClicked: OnExploreItemClicked -) { - // Reading datesSelected State from here instead of passing the String from the ViewModel - // to cause a recomposition when the dates change. - val datesSelected = viewModel.datesSelected.toString() - - when (tabSelected) { - CraneScreen.Fly -> FlySearchContent( - datesSelected, - searchUpdates = FlySearchContentUpdates( - onPeopleChanged = onPeopleChanged, - onToDestinationChanged = { viewModel.toDestinationChanged(it) }, - onDateSelectionClicked = onDateSelectionClicked, - onExploreItemClicked = onExploreItemClicked - ) - ) - CraneScreen.Sleep -> SleepSearchContent( - datesSelected, - sleepUpdates = SleepSearchContentUpdates( - onPeopleChanged = onPeopleChanged, - onDateSelectionClicked = onDateSelectionClicked, - onExploreItemClicked = onExploreItemClicked - ) - ) - CraneScreen.Eat -> EatSearchContent( - datesSelected, - eatUpdates = EatSearchContentUpdates( - onPeopleChanged = onPeopleChanged, - onDateSelectionClicked = onDateSelectionClicked, - onExploreItemClicked = onExploreItemClicked - ) - ) - } -} - -data class FlySearchContentUpdates( - val onPeopleChanged: (Int) -> Unit, - val onToDestinationChanged: (String) -> Unit, - val onDateSelectionClicked: () -> Unit, - val onExploreItemClicked: OnExploreItemClicked -) - -data class SleepSearchContentUpdates( - val onPeopleChanged: (Int) -> Unit, - val onDateSelectionClicked: () -> Unit, - val onExploreItemClicked: OnExploreItemClicked -) - -data class EatSearchContentUpdates( - val onPeopleChanged: (Int) -> Unit, - val onDateSelectionClicked: () -> Unit, - val onExploreItemClicked: OnExploreItemClicked -) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/HomeFeatures.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/HomeFeatures.kt deleted file mode 100644 index 7622f64d08..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/HomeFeatures.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.runtime.Composable -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.base.SimpleUserInput -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun FlySearchContent(datesSelected: String, searchUpdates: FlySearchContentUpdates) { - CraneSearch { - PeopleUserInput( - titleSuffix = ", Economy", - onPeopleChanged = searchUpdates.onPeopleChanged - ) - Spacer(Modifier.preferredHeight(8.dp)) - FromDestination() - Spacer(Modifier.preferredHeight(8.dp)) - ToDestinationUserInput(onToDestinationChanged = searchUpdates.onToDestinationChanged) - Spacer(Modifier.preferredHeight(8.dp)) - DatesUserInput(datesSelected, onDateSelectionClicked = searchUpdates.onDateSelectionClicked) - } -} - -@Composable -fun SleepSearchContent(datesSelected: String, sleepUpdates: SleepSearchContentUpdates) { - CraneSearch { - PeopleUserInput(onPeopleChanged = { sleepUpdates.onPeopleChanged }) - Spacer(Modifier.preferredHeight(8.dp)) - DatesUserInput(datesSelected, onDateSelectionClicked = sleepUpdates.onDateSelectionClicked) - Spacer(Modifier.preferredHeight(8.dp)) - SimpleUserInput(caption = "Select Location", vectorImageId = R.drawable.ic_hotel) - } -} - -@Composable -fun EatSearchContent(datesSelected: String, eatUpdates: EatSearchContentUpdates) { - CraneSearch { - PeopleUserInput(onPeopleChanged = { eatUpdates.onPeopleChanged }) - Spacer(Modifier.preferredHeight(8.dp)) - DatesUserInput(datesSelected, onDateSelectionClicked = eatUpdates.onDateSelectionClicked) - Spacer(Modifier.preferredHeight(8.dp)) - SimpleUserInput(caption = "Select Time", vectorImageId = R.drawable.ic_time) - Spacer(Modifier.preferredHeight(8.dp)) - SimpleUserInput(caption = "Select Location", vectorImageId = R.drawable.ic_restaurant) - } -} - -@Composable -private fun CraneSearch(content: @Composable () -> Unit) { - Column(Modifier.padding(start = 24.dp, top = 0.dp, end = 24.dp, bottom = 12.dp)) { - content() - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/LandingScreen.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/LandingScreen.kt deleted file mode 100644 index 61b1df3245..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/LandingScreen.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.samples.crane.R -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.vectorResource -import kotlinx.coroutines.delay - -private const val SplashWaitTime: Long = 2000 - -@Composable -fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - // Adds composition consistency. Use the value when LaunchedEffect is first called - val currentOnTimeout by rememberUpdatedState(onTimeout) - - LaunchedEffect(Unit) { - delay(SplashWaitTime) - currentOnTimeout() - } - Image(imageVector = vectorResource(id = R.drawable.ic_crane_drawer)) - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainActivity.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainActivity.kt deleted file mode 100644 index 1a32a7dc90..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainActivity.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.annotation.VisibleForTesting -import androidx.compose.animation.DpPropKey -import androidx.compose.animation.core.FloatPropKey -import androidx.compose.animation.core.Spring.StiffnessLow -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.transitionDefinition -import androidx.compose.animation.core.tween -import androidx.compose.animation.transition -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.base.CraneScaffold -import androidx.compose.samples.crane.calendar.launchCalendarActivity -import androidx.compose.samples.crane.details.launchDetailsActivity -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.setContent -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - MainScreen( - onExploreItemClicked = { launchDetailsActivity(context = this, item = it) }, - onDateSelectionClicked = { launchCalendarActivity(this) } - ) - } - } -} - -@VisibleForTesting -@Composable -fun MainScreen(onExploreItemClicked: OnExploreItemClicked, onDateSelectionClicked: () -> Unit) { - CraneScaffold { - var splashShown by remember { mutableStateOf(SplashState.Shown) } - val transition = transition(splashTransitionDefinition, splashShown) - Box { - LandingScreen( - modifier = Modifier.alpha(transition[splashAlphaKey]), - onTimeout = { splashShown = SplashState.Completed } - ) - MainContent( - modifier = Modifier.alpha(transition[contentAlphaKey]), - topPadding = transition[contentTopPaddingKey], - onExploreItemClicked = onExploreItemClicked, - onDateSelectionClicked = onDateSelectionClicked - ) - } - } -} - -@Composable -private fun MainContent( - modifier: Modifier = Modifier, - topPadding: Dp = 0.dp, - onExploreItemClicked: OnExploreItemClicked, - onDateSelectionClicked: () -> Unit -) { - Column(modifier = modifier) { - Spacer(Modifier.padding(top = topPadding)) - CraneHome( - modifier = modifier, - onExploreItemClicked = onExploreItemClicked, - onDateSelectionClicked = onDateSelectionClicked - ) - } -} - -enum class SplashState { Shown, Completed } - -private val splashAlphaKey = FloatPropKey("Splash alpha") -private val contentAlphaKey = FloatPropKey("Content alpha") -private val contentTopPaddingKey = DpPropKey("Top padding") - -private val splashTransitionDefinition = transitionDefinition<SplashState> { - state(SplashState.Shown) { - this[splashAlphaKey] = 1f - this[contentAlphaKey] = 0f - this[contentTopPaddingKey] = 100.dp - } - state(SplashState.Completed) { - this[splashAlphaKey] = 0f - this[contentAlphaKey] = 1f - this[contentTopPaddingKey] = 0.dp - } - transition { - splashAlphaKey using tween( - durationMillis = 100 - ) - contentAlphaKey using tween( - durationMillis = 300 - ) - contentTopPaddingKey using spring( - stiffness = StiffnessLow - ) - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainViewModel.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainViewModel.kt deleted file mode 100644 index c9f6399e89..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/MainViewModel.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.samples.crane.calendar.model.DatesSelectedState -import androidx.compose.samples.crane.data.DatesRepository -import androidx.compose.samples.crane.data.DestinationsRepository -import androidx.compose.samples.crane.data.ExploreModel -import androidx.compose.samples.crane.di.DefaultDispatcher -import androidx.hilt.lifecycle.ViewModelInject -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.random.Random - -const val MAX_PEOPLE = 4 - -class MainViewModel @ViewModelInject constructor( - private val destinationsRepository: DestinationsRepository, - @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, - datesRepository: DatesRepository -) : ViewModel() { - - val hotels: List<ExploreModel> = destinationsRepository.hotels - val restaurants: List<ExploreModel> = destinationsRepository.restaurants - val datesSelected: DatesSelectedState = datesRepository.datesSelected - - private val _suggestedDestinations = MutableLiveData<List<ExploreModel>>() - val suggestedDestinations: LiveData<List<ExploreModel>> - get() = _suggestedDestinations - - init { - _suggestedDestinations.value = destinationsRepository.destinations - } - - fun updatePeople(people: Int) { - viewModelScope.launch { - if (people > MAX_PEOPLE) { - _suggestedDestinations.value = emptyList() - } else { - val newDestinations = withContext(defaultDispatcher) { - destinationsRepository.destinations - .shuffled(Random(people * (1..100).shuffled().first())) - } - _suggestedDestinations.value = newDestinations - } - } - } - - fun toDestinationChanged(newDestination: String) { - viewModelScope.launch { - val newDestinations = withContext(defaultDispatcher) { - destinationsRepository.destinations - .filter { it.city.nameToDisplay.contains(newDestination) } - } - _suggestedDestinations.value = newDestinations - } - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/home/SearchUserInput.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/home/SearchUserInput.kt deleted file mode 100644 index c38fc5e942..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/home/SearchUserInput.kt +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.home - -import androidx.compose.animation.ColorPropKey -import androidx.compose.animation.core.transitionDefinition -import androidx.compose.animation.core.tween -import androidx.compose.animation.transition -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.samples.crane.R -import androidx.compose.samples.crane.base.CraneEditableUserInput -import androidx.compose.samples.crane.base.CraneUserInput -import androidx.compose.samples.crane.home.PeopleUserInputAnimationState.Invalid -import androidx.compose.samples.crane.home.PeopleUserInputAnimationState.Valid -import androidx.compose.samples.crane.ui.CraneTheme -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview - -class PeopleUserInputState { - var people by mutableStateOf(1) - private set - - var animationState: PeopleUserInputAnimationState = Valid - private set - - fun addPerson() { - people = (people % (MAX_PEOPLE + 1)) + 1 - updateAnimationState() - } - - private fun updateAnimationState() { - val newState = - if (people > MAX_PEOPLE) Invalid - else Valid - - if (animationState != newState) animationState = newState - } -} - -@Composable -fun PeopleUserInput( - titleSuffix: String? = "", - onPeopleChanged: (Int) -> Unit, - peopleState: PeopleUserInputState = remember { PeopleUserInputState() } -) { - Column { - val validColor = MaterialTheme.colors.onSurface - val invalidColor = MaterialTheme.colors.secondary - val transitionDefinition = - remember(validColor, invalidColor) { - generateTransitionDefinition( - validColor, - invalidColor - ) - } - - val transition = transition(transitionDefinition, peopleState.animationState) - val people = peopleState.people - CraneUserInput( - modifier = Modifier.clickable { - peopleState.addPerson() - onPeopleChanged(peopleState.people) - }, - text = if (people == 1) "$people Adult$titleSuffix" else "$people Adults$titleSuffix", - vectorImageId = R.drawable.ic_person, - tint = transition[tintKey] - ) - if (peopleState.animationState == Invalid) { - Text( - text = "Error: We don't support more than $MAX_PEOPLE people", - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary) - ) - } - } -} - -@Composable -fun FromDestination() { - CraneUserInput(text = "Seoul, South Korea", vectorImageId = R.drawable.ic_location) -} - -@Composable -fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) { - CraneEditableUserInput( - hint = "Choose Destination", - caption = "To", - vectorImageId = R.drawable.ic_plane, - onInputChanged = onToDestinationChanged - ) -} - -@Composable -fun DatesUserInput(datesSelected: String, onDateSelectionClicked: () -> Unit) { - CraneUserInput( - modifier = Modifier.clickable(onClick = onDateSelectionClicked), - caption = if (datesSelected.isEmpty()) "Select Dates" else null, - text = datesSelected, - vectorImageId = R.drawable.ic_calendar - ) -} - -@Preview -@Composable -fun PeopleUserInputPreview() { - CraneTheme { - PeopleUserInput(onPeopleChanged = {}) - } -} - -private val tintKey = ColorPropKey(label = "tint") - -enum class PeopleUserInputAnimationState { Valid, Invalid } - -private fun generateTransitionDefinition( - validColor: Color, - invalidColor: Color -) = transitionDefinition<PeopleUserInputAnimationState> { - state(Valid) { - this[tintKey] = validColor - } - state(Invalid) { - this[tintKey] = invalidColor - } - transition(fromState = Valid) { - tintKey using tween( - durationMillis = 300 - ) - } - transition(fromState = Invalid) { - tintKey using tween( - durationMillis = 300 - ) - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/ui/CraneTheme.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/ui/CraneTheme.kt deleted file mode 100644 index 85531fe883..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/ui/CraneTheme.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.ui - -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.shape.ZeroCornerSize -import androidx.compose.material.MaterialTheme -import androidx.compose.material.lightColors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -val crane_caption = Color.DarkGray -val crane_divider_color = Color.LightGray -private val crane_red = Color(0xFFE30425) -private val crane_white = Color.White -private val crane_purple_700 = Color(0xFF720D5D) -private val crane_purple_800 = Color(0xFF5D1049) -private val crane_purple_900 = Color(0xFF4E0D3A) - -val craneColors = lightColors( - primary = crane_purple_800, - secondary = crane_red, - surface = crane_purple_900, - onSurface = crane_white, - primaryVariant = crane_purple_700 -) - -val BottomSheetShape = RoundedCornerShape( - topLeft = CornerSize(20.dp), - topRight = CornerSize(20.dp), - bottomLeft = ZeroCornerSize, - bottomRight = ZeroCornerSize -) - -@Composable -fun CraneTheme(content: @Composable () -> Unit) { - MaterialTheme(colors = craneColors, typography = craneTypography) { - content() - } -} diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/ui/Typography.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/ui/Typography.kt deleted file mode 100644 index d1e94676af..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/ui/Typography.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.ui - -import androidx.compose.material.Typography -import androidx.compose.samples.crane.R -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.font.font -import androidx.compose.ui.text.font.fontFamily -import androidx.compose.ui.unit.sp - -private val light = font(R.font.raleway_light, FontWeight.W300) -private val regular = font(R.font.raleway_regular, FontWeight.W400) -private val medium = font(R.font.raleway_medium, FontWeight.W500) -private val semibold = font(R.font.raleway_semibold, FontWeight.W600) - -private val craneFontFamily = fontFamily(fonts = listOf(light, regular, medium, semibold)) - -val captionTextStyle = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 16.sp -) - -val craneTypography = Typography( - h1 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W300, - fontSize = 96.sp - ), - h2 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 60.sp - ), - h3 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 48.sp - ), - h4 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 34.sp - ), - h5 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 24.sp - ), - h6 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 20.sp - ), - subtitle1 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W500, - fontSize = 16.sp - ), - subtitle2 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 14.sp - ), - body1 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 16.sp - ), - body2 = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 14.sp - ), - button = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W600, - fontSize = 14.sp - ), - caption = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W500, - fontSize = 12.sp - ), - overline = TextStyle( - fontFamily = craneFontFamily, - fontWeight = FontWeight.W400, - fontSize = 12.sp - ) -) diff --git a/Crane/app/src/main/java/androidx/compose/samples/crane/util/Shapes.kt b/Crane/app/src/main/java/androidx/compose/samples/crane/util/Shapes.kt deleted file mode 100644 index 40ff1d1310..0000000000 --- a/Crane/app/src/main/java/androidx/compose/samples/crane/util/Shapes.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.compose.samples.crane.util - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color - -@Composable -fun Circle(color: Color) { - Canvas(Modifier.fillMaxSize()) { - drawCircle(color) - } -} - -@Composable -fun SemiRect(color: Color, lookingLeft: Boolean = true) { - Canvas(Modifier.fillMaxSize()) { - val offset = if (lookingLeft) { - Offset(0f, 0f) - } else { - Offset(size.width / 2, 0f) - } - val size = Size(width = size.width / 2, height = size.height) - - drawRect(size = size, topLeft = offset, color = color) - } -} diff --git a/Crane/app/src/main/res/drawable/ic_back.xml b/Crane/app/src/main/res/drawable/ic_back.xml deleted file mode 100644 index 2989fdadae..0000000000 --- a/Crane/app/src/main/res/drawable/ic_back.xml +++ /dev/null @@ -1,10 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24.0" - android:viewportHeight="24.0" - android:tint="?attr/colorControlNormal"> - <path - android:fillColor="@android:color/white" - android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_calendar.xml b/Crane/app/src/main/res/drawable/ic_calendar.xml deleted file mode 100644 index 49dc42f7d9..0000000000 --- a/Crane/app/src/main/res/drawable/ic_calendar.xml +++ /dev/null @@ -1,26 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24.0" - android:viewportHeight="24.0" - android:tint="?attr/colorControlNormal"> - <path - android:fillColor="@android:color/white" - android:pathData="M20,3h-1L19,1h-2v2L7,3L7,1L5,1v2L4,3c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,5c0,-1.1 -0.9,-2 -2,-2zM20,21L4,21L4,8h16v13z"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_crane_drawer.xml b/Crane/app/src/main/res/drawable/ic_crane_drawer.xml deleted file mode 100644 index 43d2052ef3..0000000000 --- a/Crane/app/src/main/res/drawable/ic_crane_drawer.xml +++ /dev/null @@ -1,34 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="72dp" - android:height="92dp" - android:viewportWidth="72" - android:viewportHeight="92"> - <path - android:pathData="M30.8125,27.4307L36.9555,27.4217L42.1235,27.4217C42.5085,25.2727 41.7325,22.4857 39.9905,21.1757C38.3875,19.9697 36.1505,19.6617 34.2815,20.3897C32.1095,21.2357 30.4065,23.1527 28.6575,24.7197L30.8125,27.4307Z" - android:fillColor="#E30425" - android:fillType="evenOdd"/> - <path - android:pathData="M17.9905,33.704l1.529,1.951l11.293,-8.224l-2.155,-2.711z" - android:fillColor="#FFFFFF" - android:fillType="evenOdd"/> - <path - android:pathData="M38.9031,45.9591C43.0891,49.3771 46.8441,53.3931 49.6301,57.6251C49.6301,57.6251 44.2821,55.4891 38.9031,53.3301L38.9031,45.9591ZM34.7221,42.8451L34.7221,51.6501C31.0911,50.1881 28.1781,49.0051 27.9981,48.9031C26.8431,48.2491 25.9961,47.0761 25.7391,45.7741C25.4821,44.4731 25.8211,43.0661 26.6411,42.0231C27.4811,40.9541 28.2611,40.2721 29.7721,39.8571C31.4411,40.7391 33.0981,41.7461 34.7221,42.8451L34.7221,42.8451ZM68.0001,57.0001C68.0001,69.6801 62.8961,78.7271 52.8311,83.8921C47.3101,86.7261 41.7311,87.6031 38.9031,87.8751L38.9031,57.6521L52.1381,62.4691L52.1401,62.4641L57.9911,64.9411C53.3711,53.1351 44.4661,42.5061 33.4371,36.2061L33.4451,36.1991C33.4451,36.1991 41.7701,28.9331 42.1231,27.4221L36.9611,27.4221L36.9551,27.4221L24.6841,37.7191L24.6901,37.7271C22.6051,39.4091 20.3661,42.1951 20.3661,45.0831C20.3661,49.4081 24.2631,52.8201 28.2911,53.7901L34.7221,56.1311L34.7221,87.9931C22.1901,87.9171 13.2341,82.8211 8.1071,72.8311C4.1011,65.0241 4.0011,57.0731 4.0001,57.0001L4.0001,35.0001C4.0001,22.3201 9.1031,13.2721 19.1691,8.1071C26.9411,4.1191 34.8571,4.0011 35.0051,4.0001L37.0001,4.0001C49.6801,4.0001 58.7271,9.1031 63.8931,19.1691C67.8981,26.9751 67.9991,34.9271 68.0001,35.0001L68.0001,57.0001ZM37.0001,0.0001L35.0001,0.0001C34.6501,0.0001 0.0001,0.4001 0.0001,35.0001L0.0001,57.0001C0.0001,57.3501 0.4001,92.0001 35.0001,92.0001L37.0001,92.0001C37.3501,92.0001 72.0001,91.6001 72.0001,57.0001L72.0001,35.0001C72.0001,34.6501 71.6001,0.0001 37.0001,0.0001L37.0001,0.0001Z" - android:fillColor="#FFFFFF" - android:fillType="evenOdd"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_crane_logo.xml b/Crane/app/src/main/res/drawable/ic_crane_logo.xml deleted file mode 100644 index fae4ebb91f..0000000000 --- a/Crane/app/src/main/res/drawable/ic_crane_logo.xml +++ /dev/null @@ -1,38 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="35dp" - android:height="48dp" - android:viewportWidth="35" - android:viewportHeight="48"> - <path - android:pathData="M0,12.0766l1.3261,1.7184l9.8007,-7.2465l-1.8694,-2.3891z" - android:fillColor="#FFFFFF" - android:fillType="evenOdd"/> - <path - android:pathData="M13.4049,14.2816L13.4119,14.2755C13.4119,14.2755 20.6368,7.8723 20.9431,6.5407L16.4632,6.5407L16.4632,6.5407L16.458,6.5407L5.8086,15.615L5.8138,15.622C4.0043,17.1043 2.0612,19.5594 2.0612,22.1045C2.0612,25.9159 5.4432,28.9227 8.9389,29.7775L29.6347,37.4259L29.6365,37.4215L34.7143,39.6043C30.7048,29.2003 22.9765,19.8335 13.4049,14.2816M12.8859,9.589L12.8859,9.589C12.8851,9.5881 12.8842,9.5881 12.8842,9.5872C12.8842,9.5881 12.8851,9.5881 12.8859,9.589M8.6846,25.4709C7.6823,24.8945 6.9472,23.8608 6.7242,22.7134C6.5011,21.5669 6.7953,20.327 7.507,19.4079C8.236,18.4658 8.9129,17.8648 10.2242,17.4991C16.8833,21.0734 23.3679,26.8483 27.4581,33.1571C27.4581,33.1571 9.1134,25.7176 8.6846,25.4709" - android:fillColor="#FFFFFF" - android:fillType="evenOdd"/> - <path - android:pathData="M11.1272,6.5483L16.4585,6.5404L20.9436,6.5404C21.2777,4.6466 20.6042,2.1905 19.0924,1.0361C17.7012,-0.0267 15.7599,-0.2981 14.1378,0.3434C12.2528,1.089 10.7749,2.7783 9.257,4.1592L11.1272,6.5483Z" - android:fillColor="#E30425" - android:fillType="evenOdd"/> - <path - android:pathData="M14.625,47.9987l3.6285,0l0,-28.8737l-3.6285,0z" - android:fillColor="#FFFFFF" - android:fillType="evenOdd"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_hotel.xml b/Crane/app/src/main/res/drawable/ic_hotel.xml deleted file mode 100644 index 1ae921185c..0000000000 --- a/Crane/app/src/main/res/drawable/ic_hotel.xml +++ /dev/null @@ -1,26 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24.0" - android:viewportHeight="24.0" - android:tint="?attr/colorControlNormal"> - <path - android:fillColor="@android:color/white" - android:pathData="M7,13c1.66,0 3,-1.34 3,-3S8.66,7 7,7s-3,1.34 -3,3 1.34,3 3,3zM19,7h-8v7L3,14L3,5L1,5v15h2v-3h18v3h2v-9c0,-2.21 -1.79,-4 -4,-4z"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_launcher_foreground.xml b/Crane/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 499e509030..0000000000 --- a/Crane/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,55 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="108dp" - android:height="108dp" - android:viewportWidth="108" - android:viewportHeight="108"> - <group android:scaleX="1.2030159" - android:scaleY="1.2030159" - android:translateX="33.05" - android:translateY="24.97"> - <path - android:pathData="M0,12.0766l1.3261,1.7184l9.8007,-7.2465l-1.8694,-2.3891z" - android:strokeWidth="1" - android:fillColor="#FFFFFF" - android:fillType="evenOdd" - android:strokeColor="#00000000"/> - <path - android:pathData="M13.4049,14.2816L13.4119,14.2755C13.4119,14.2755 20.6368,7.8723 20.9431,6.5407L16.4632,6.5407L16.4632,6.5407L16.458,6.5407L5.8086,15.615L5.8138,15.622C4.0043,17.1043 2.0612,19.5594 2.0612,22.1045C2.0612,25.9159 5.4432,28.9227 8.9389,29.7775L29.6347,37.4259L29.6365,37.4215L34.7143,39.6043C30.7048,29.2003 22.9765,19.8335 13.4049,14.2816M12.8859,9.589L12.8859,9.589C12.8851,9.5881 12.8842,9.5881 12.8842,9.5872C12.8842,9.5881 12.8851,9.5881 12.8859,9.589M8.6846,25.4709C7.6823,24.8945 6.9472,23.8608 6.7242,22.7134C6.5011,21.5669 6.7953,20.327 7.507,19.4079C8.236,18.4658 8.9129,17.8648 10.2242,17.4991C16.8833,21.0734 23.3679,26.8483 27.4581,33.1571C27.4581,33.1571 9.1134,25.7176 8.6846,25.4709" - android:strokeWidth="1" - android:fillColor="#FFFFFF" - android:fillType="evenOdd" - android:strokeColor="#00000000"/> - <group> - <clip-path - android:pathData="M21.0275,0l-11.7705,0l0,6.5483l11.7705,0l0,-6.5483z"/> - <path - android:pathData="M11.1272,6.5483L16.4585,6.5404L20.9436,6.5404C21.2777,4.6466 20.6042,2.1905 19.0924,1.0361C17.7012,-0.0267 15.7599,-0.2981 14.1378,0.3434C12.2528,1.089 10.7749,2.7783 9.257,4.1592L11.1272,6.5483Z" - android:strokeWidth="1" - android:fillColor="#E30425" - android:fillType="evenOdd" - android:strokeColor="#00000000"/> - </group> - <path - android:pathData="M14.625,48.0185l3.6285,0l0,-28.8935l-3.6285,0z" - android:strokeWidth="1" - android:fillColor="#FFFFFF" - android:fillType="evenOdd" - android:strokeColor="#00000000"/> - </group> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_location.xml b/Crane/app/src/main/res/drawable/ic_location.xml deleted file mode 100644 index 76676634e1..0000000000 --- a/Crane/app/src/main/res/drawable/ic_location.xml +++ /dev/null @@ -1,28 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="14dp" - android:height="20dp" - android:viewportWidth="14" - android:viewportHeight="20"> - <path - android:pathData="M7,0C3.13,0 0,3.13 0,7C0,12.25 7,20 7,20C7,20 14,12.25 14,7C14,3.13 10.87,0 7,0ZM7,9.5C5.62,9.5 4.5,8.38 4.5,7C4.5,5.62 5.62,4.5 7,4.5C8.38,4.5 9.5,5.62 9.5,7C9.5,8.38 8.38,9.5 7,9.5Z" - android:strokeWidth="1" - android:fillColor="#FFFFFF" - android:fillType="nonZero" - android:strokeColor="#00000000"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_menu.xml b/Crane/app/src/main/res/drawable/ic_menu.xml deleted file mode 100644 index 3137ba3502..0000000000 --- a/Crane/app/src/main/res/drawable/ic_menu.xml +++ /dev/null @@ -1,46 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="15dp" - android:height="17dp" - android:viewportWidth="15" - android:viewportHeight="17"> - <path - android:pathData="M-4.5,1.5L12.6172,1.5" - android:strokeLineJoin="round" - android:strokeWidth="3" - android:fillColor="#00000000" - android:strokeColor="#FFFFFF" - android:fillType="evenOdd" - android:strokeLineCap="round"/> - <path - android:pathData="M-4.5,8.5L12.6172,8.5" - android:strokeLineJoin="round" - android:strokeWidth="3" - android:fillColor="#00000000" - android:strokeColor="#FFFFFF" - android:fillType="evenOdd" - android:strokeLineCap="round"/> - <path - android:pathData="M-4.5,15.5L12.6172,15.5" - android:strokeLineJoin="round" - android:strokeWidth="3" - android:fillColor="#00000000" - android:strokeColor="#FFFFFF" - android:fillType="evenOdd" - android:strokeLineCap="round"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_person.xml b/Crane/app/src/main/res/drawable/ic_person.xml deleted file mode 100644 index b728d4db9e..0000000000 --- a/Crane/app/src/main/res/drawable/ic_person.xml +++ /dev/null @@ -1,26 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24.0" - android:viewportHeight="24.0" - android:tint="?attr/colorControlNormal"> - <path - android:fillColor="@android:color/white" - android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_plane.xml b/Crane/app/src/main/res/drawable/ic_plane.xml deleted file mode 100644 index 056c486e9d..0000000000 --- a/Crane/app/src/main/res/drawable/ic_plane.xml +++ /dev/null @@ -1,26 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24.0" - android:viewportHeight="24.0" - android:tint="?attr/colorControlNormal"> - <path - android:fillColor="@android:color/white" - android:pathData="M21,16v-2l-8,-5V3.5c0,-0.83 -0.67,-1.5 -1.5,-1.5S10,2.67 10,3.5V9l-8,5v2l8,-2.5V19l-2,1.5V22l3.5,-1 3.5,1v-1.5L13,19v-5.5l8,2.5z"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_restaurant.xml b/Crane/app/src/main/res/drawable/ic_restaurant.xml deleted file mode 100644 index 89977ee7dc..0000000000 --- a/Crane/app/src/main/res/drawable/ic_restaurant.xml +++ /dev/null @@ -1,26 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24.0" - android:viewportHeight="24.0" - android:tint="?attr/colorControlNormal"> - <path - android:fillColor="@android:color/white" - android:pathData="M8.1,13.34l2.83,-2.83L3.91,3.5c-1.56,1.56 -1.56,4.09 0,5.66l4.19,4.18zM14.88,11.53c1.53,0.71 3.68,0.21 5.27,-1.38 1.91,-1.91 2.28,-4.65 0.81,-6.12 -1.46,-1.46 -4.2,-1.1 -6.12,0.81 -1.59,1.59 -2.09,3.74 -1.38,5.27L3.7,19.87l1.41,1.41L12,14.41l6.88,6.88 1.41,-1.41L13.41,13l1.47,-1.47z"/> -</vector> diff --git a/Crane/app/src/main/res/drawable/ic_time.xml b/Crane/app/src/main/res/drawable/ic_time.xml deleted file mode 100644 index 0db2db0fb6..0000000000 --- a/Crane/app/src/main/res/drawable/ic_time.xml +++ /dev/null @@ -1,30 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> - <group> - <clip-path android:pathData="M0,0h24v24H0V0z M 0,0"/> - <path - android:fillColor="@android:color/white" - android:pathData="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10 10,-4.5 10,-10S17.5,2 12,2zM16.2,16.2L11,13L11,7h1.5v5.2l4.5,2.7 -0.8,1.3z"/> - </group> -</vector> - diff --git a/Crane/app/src/main/res/font/raleway_light.ttf b/Crane/app/src/main/res/font/raleway_light.ttf deleted file mode 100755 index b5ec486060..0000000000 Binary files a/Crane/app/src/main/res/font/raleway_light.ttf and /dev/null differ diff --git a/Crane/app/src/main/res/font/raleway_medium.ttf b/Crane/app/src/main/res/font/raleway_medium.ttf deleted file mode 100755 index 070ac7691f..0000000000 Binary files a/Crane/app/src/main/res/font/raleway_medium.ttf and /dev/null differ diff --git a/Crane/app/src/main/res/font/raleway_regular.ttf b/Crane/app/src/main/res/font/raleway_regular.ttf deleted file mode 100755 index 746c242383..0000000000 Binary files a/Crane/app/src/main/res/font/raleway_regular.ttf and /dev/null differ diff --git a/Crane/app/src/main/res/font/raleway_semibold.ttf b/Crane/app/src/main/res/font/raleway_semibold.ttf deleted file mode 100755 index 34db420617..0000000000 Binary files a/Crane/app/src/main/res/font/raleway_semibold.ttf and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Crane/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 7353dbd1fd..0000000000 --- a/Crane/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@color/ic_launcher_background"/> - <foreground android:drawable="@drawable/ic_launcher_foreground"/> -</adaptive-icon> \ No newline at end of file diff --git a/Crane/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index c3bc12a985..0000000000 Binary files a/Crane/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index ac3662d8db..0000000000 Binary files a/Crane/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index d16629b965..0000000000 Binary files a/Crane/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 5cb5cfe5b3..0000000000 Binary files a/Crane/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Crane/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index ba761daff2..0000000000 Binary files a/Crane/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Crane/app/src/main/res/values/colors.xml b/Crane/app/src/main/res/values/colors.xml deleted file mode 100644 index cc7848b999..0000000000 --- a/Crane/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<resources> - <color name="colorPrimary">#5D1049</color> - <color name="colorPrimaryDark">#3D0A2C</color> - <color name="colorAccent">#E30425</color> -</resources> \ No newline at end of file diff --git a/Crane/app/src/main/res/values/ic_launcher_background.xml b/Crane/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index ec64c6ca8d..0000000000 --- a/Crane/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<resources> - <color name="ic_launcher_background">#5D1049</color> -</resources> \ No newline at end of file diff --git a/Crane/app/src/main/res/values/ids.xml b/Crane/app/src/main/res/values/ids.xml deleted file mode 100644 index c873906ccf..0000000000 --- a/Crane/app/src/main/res/values/ids.xml +++ /dev/null @@ -1,19 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<resources> - <item name="map" type="id" /> -</resources> \ No newline at end of file diff --git a/Crane/app/src/main/res/values/strings.xml b/Crane/app/src/main/res/values/strings.xml deleted file mode 100644 index 58d56fb370..0000000000 --- a/Crane/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,19 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<resources> - <string name="app_name">Crane</string> -</resources> \ No newline at end of file diff --git a/Crane/app/src/main/res/values/styles.xml b/Crane/app/src/main/res/values/styles.xml deleted file mode 100644 index 79df355fd1..0000000000 --- a/Crane/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,25 +0,0 @@ -<!-- - ~ Copyright 2019 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 - ~ - ~ 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. - ~ - --> -<resources> - <!-- Base application theme. --> - <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar"> - <!-- Customize your theme here. --> - <item name="colorPrimary">@color/colorPrimary</item> - <item name="colorPrimaryDark">@color/colorPrimaryDark</item> - <item name="colorAccent">@color/colorAccent</item> - </style> -</resources> \ No newline at end of file diff --git a/Crane/app/src/release/res/values/google_maps_api.xml b/Crane/app/src/release/res/values/google_maps_api.xml deleted file mode 100644 index b4a93a82c6..0000000000 --- a/Crane/app/src/release/res/values/google_maps_api.xml +++ /dev/null @@ -1,19 +0,0 @@ -<!-- - ~ Copyright 2020 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 - ~ - ~ 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. - ~ - --> -<resources> - <string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">YOUR_KEY_HERE</string> -</resources> \ No newline at end of file diff --git a/Crane/build.gradle b/Crane/build.gradle deleted file mode 100644 index 1e1b42fadc..0000000000 --- a/Crane/build.gradle +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.crane.buildsrc.Libs -import com.example.crane.buildsrc.Urls -import com.example.crane.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - classpath Libs.Hilt.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.8.2' -} - -subprojects { - repositories { - google() - jcenter() - mavenCentral() - - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { url Urls.composeSnapshotRepo } - maven { url Urls.mavenCentralSnapshotRepo } - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - ktlint(Versions.ktLint) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - jvmTarget = "1.8" - - // Use experimental APIs - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - freeCompilerArgs += '-Xallow-jvm-ir-dependencies' - freeCompilerArgs += [ - '-P', - 'plugin:androidx.compose.compiler.plugins.kotlin:intrinsicRemember=true' - ] - } - } -} \ No newline at end of file diff --git a/Crane/buildSrc/build.gradle.kts b/Crane/buildSrc/build.gradle.kts deleted file mode 100644 index 2567fced51..0000000000 --- a/Crane/buildSrc/build.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import org.gradle.kotlin.dsl.`kotlin-dsl` - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Crane/buildSrc/src/main/java/com/example/crane/buildsrc/Dependencies.kt b/Crane/buildSrc/src/main/java/com/example/crane/buildsrc/Dependencies.kt deleted file mode 100644 index 7b6e11b6fa..0000000000 --- a/Crane/buildSrc/src/main/java/com/example/crane/buildsrc/Dependencies.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.crane.buildsrc - -object Versions { - const val ktLint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha04" - const val ktLint = "com.pinterest:ktlint:${Versions.ktLint}" - - object GoogleMaps { - const val maps = "com.google.android.libraries.maps:maps:3.1.0-beta" - const val mapsKtx = "com.google.maps.android:maps-v3-ktx:2.2.0" - } - - object Accompanist { - private const val version = "0.4.2" - const val coil = "dev.chrisbanes.accompanist:accompanist-coil:$version" - } - - object Kotlin { - private const val version = "1.4.21" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - - object Coroutines { - private const val version = "1.4.2" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - } - - object AndroidX { - object Compose { - const val snapshot = "" - private const val version = "1.0.0-alpha10" - - const val runtime = "androidx.compose.runtime:runtime:$version" - const val runtimeLivedata = "androidx.compose.runtime:runtime-livedata:$version" - const val material = "androidx.compose.material:material:$version" - const val foundation = "androidx.compose.foundation:foundation:$version" - const val layout = "androidx.compose.foundation:foundation-layout:$version" - const val tooling = "androidx.compose.ui:ui-tooling:$version" - const val animation = "androidx.compose.animation:animation:$version" - const val uiTest = "androidx.compose.ui:ui-test-junit4:$version" - } - - object Lifecycle { - private const val version = "2.3.0-beta01" - const val viewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - } - - object Test { - private const val version = "1.2.0" - const val runner = "androidx.test:runner:$version" - const val rules = "androidx.test:rules:$version" - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - } - - object Hilt { - private const val version = "2.30.1-alpha" - - const val gradlePlugin = "com.google.dagger:hilt-android-gradle-plugin:$version" - const val android = "com.google.dagger:hilt-android:$version" - const val compiler = "com.google.dagger:hilt-compiler:$version" - const val testing = "com.google.dagger:hilt-android-testing:$version" - - object AndroidX { - private const val version = "1.0.0-alpha02" - - const val compiler = "androidx.hilt:hilt-compiler:$version" - const val viewModel = "androidx.hilt:hilt-lifecycle-viewmodel:$version" - } - } - - object JUnit { - private const val version = "4.13" - const val junit = "junit:junit:$version" - } - - object AssistedInjection { - private const val version = "0.5.2" - - const val dagger = "com.squareup.inject:assisted-inject-annotations-dagger2:$version" - const val processor = "com.squareup.inject:assisted-inject-processor-dagger2:$version" - } -} - -object Urls { - const val mavenCentralSnapshotRepo = "https://oss.sonatype.org/content/repositories/snapshots/" - const val composeSnapshotRepo = "https://androidx.dev/snapshots/builds/" + - "${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" -} diff --git a/Crane/debug.keystore b/Crane/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Crane/debug.keystore and /dev/null differ diff --git a/Crane/gradle.properties b/Crane/gradle.properties deleted file mode 100644 index 5e5ee02b5e..0000000000 --- a/Crane/gradle.properties +++ /dev/null @@ -1,45 +0,0 @@ -# -# Copyright 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m - -# Turn on parallel compilation, caching and on-demand configuration -org.gradle.configureondemand=true -org.gradle.caching=true -org.gradle.parallel=true - -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -# Needed for com.google.android.libraries.maps:maps -android.enableJetifier=true - -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Crane/gradle/wrapper/gradle-wrapper.jar b/Crane/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index f6b961fd5a..0000000000 Binary files a/Crane/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/Crane/gradle/wrapper/gradle-wrapper.properties b/Crane/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index adcbca81af..0000000000 --- a/Crane/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Fri Oct 23 09:30:32 CEST 2020 -distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-rc-1-bin.zip -distributionPath=wrapper/dists -zipStorePath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME diff --git a/Crane/gradlew b/Crane/gradlew deleted file mode 100755 index cccdd3d517..0000000000 --- a/Crane/gradlew +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env sh - -############################################################################## -## -## 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="" - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; 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=$((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" - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - -exec "$JAVACMD" "$@" diff --git a/Crane/gradlew.bat b/Crane/gradlew.bat deleted file mode 100644 index e95643d6a2..0000000000 --- a/Crane/gradlew.bat +++ /dev/null @@ -1,84 +0,0 @@ -@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 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= - -@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 init - -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 init - -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 - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -: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 %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/Crane/screenshots/crane.gif b/Crane/screenshots/crane.gif deleted file mode 100644 index c0690e52ce..0000000000 Binary files a/Crane/screenshots/crane.gif and /dev/null differ diff --git a/Crane/settings.gradle b/Crane/settings.gradle deleted file mode 100644 index e7b4def49c..0000000000 --- a/Crane/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -include ':app' diff --git a/JetLagged/.gitignore b/JetLagged/.gitignore new file mode 100644 index 0000000000..834ecd9dff --- /dev/null +++ b/JetLagged/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +.kotlin/ diff --git a/JetLagged/.google/packaging.yaml b/JetLagged/.google/packaging.yaml new file mode 100644 index 0000000000..5860ae69be --- /dev/null +++ b/JetLagged/.google/packaging.yaml @@ -0,0 +1,32 @@ +# Copyright (C) 2022 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# GOOGLE SAMPLE PACKAGING DATA +# +# This file is used by Google as part of our samples packaging process. +# End users may safely ignore this file. It has no relevance to other systems. +--- +status: PUBLISHED +technologies: [Android, JetpackCompose] +categories: + - JetpackComposeGraphics + - JetpackComposeLayouts + - JetpackComposeAnimation +languages: [Kotlin] +solutions: [Mobile] +github: android/compose-samples +level: ADVANCED +apiRefs: + - android:androidx.compose.Composable +license: apache2 diff --git a/Jetsurvey/ASSETS_LICENSE b/JetLagged/ASSETS_LICENSE similarity index 100% rename from Jetsurvey/ASSETS_LICENSE rename to JetLagged/ASSETS_LICENSE diff --git a/JetLagged/README.md b/JetLagged/README.md new file mode 100644 index 0000000000..9a5b08b049 --- /dev/null +++ b/JetLagged/README.md @@ -0,0 +1,39 @@ +# JetLagged sample + +JetLagged is a sample sleep tracking app built with [Jetpack Compose][compose]. + +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). +You can clone this repository or import the +project from Android Studio following the steps +[here](https://developer.android.com/jetpack/compose/setup#sample). + +Features: +* Medium complexity +* Custom Layouts +* Graphics: Custom Paths, Gradients, AGSL shaders +* Animations + +## Screenshots + +<img src="screenshots/screenshots.png" alt="JetLagged"/> + +## License + +``` +Copyright 2022 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +[compose]: https://developer.android.com/jetpack/compose diff --git a/Crane/buildSrc/.gitignore b/JetLagged/app/.gitignore similarity index 100% rename from Crane/buildSrc/.gitignore rename to JetLagged/app/.gitignore diff --git a/JetLagged/app/build.gradle.kts b/JetLagged/app/build.gradle.kts new file mode 100644 index 0000000000..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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + + <!--Load images from Unsplash--> + <uses-permission android:name="android.permission.INTERNET" /> + + <application + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:resizeableActivity="true" + android:enableOnBackInvokedCallback="true" + android:theme="@style/Theme.JetLagged"> + + <profileable android:shell="true" tools:targetApi="q" /> + + <activity + android:name=".MainActivity" + android:theme="@style/Theme.JetLagged" + android:exported="true" + android:configChanges="screenSize|smallestScreenSize|orientation|screenLayout|uiMode" + android:windowSoftInputMode="adjustResize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> 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<Float>() + 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<SolarFlairShaderBackgroundNode>() { + 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<MovingStripesBackgroundNode>() { + 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<HeartRateData> = 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<JetLaggedHomeScreenState> = 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<HeartRateData>) { + Box(Modifier.size(width = 400.dp, height = 100.dp)) { + Graph( + listData = listData, + modifier = Modifier.padding(16.dp) + ) + } +} + +@Composable +private fun Graph( + listData: List<HeartRateData>, + modifier: Modifier = Modifier, + waveLineColors: List<Color> = 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<HeartRateData>, size: Size): Pair<Path, Path> { + 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<HeartRateData>, + 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<TabPosition> -> + 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<Int>) { + 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<Boolean>, +) { + 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<Pair<Float, Color>> = + 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<SleepDayData>, +) { + 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<SleepPeriod>, + 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<Int>, + ): 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<Color> = 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 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="#000000"> + <group android:scaleX="0.4118" + android:scaleY="0.4118" + android:translateX="7.0584" + android:translateY="7.0584"> + <path + android:fillColor="@android:color/white" + android:pathData="M11.65,3.46c0.27,-0.71 -0.36,-1.45 -1.12,-1.34c-5.52,0.8 -9.47,6.07 -8.34,11.88c0.78,4.02 4.09,7.21 8.14,7.87c3.74,0.61 7.16,-0.87 9.32,-3.44c0.48,-0.57 0.19,-1.48 -0.55,-1.62C13.08,15.66 9.42,9.27 11.65,3.46z"/> + </group> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. +--> +<resources> + <array name="com_google_android_gms_fonts_certs"> + <item>@array/com_google_android_gms_fonts_certs_dev</item> + <item>@array/com_google_android_gms_fonts_certs_prod</item> + </array> + <string-array name="com_google_android_gms_fonts_certs_dev"> + <item> + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + </item> + </string-array> + <string-array name="com_google_android_gms_fonts_certs_prod"> + <item> + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + </item> + </string-array> +</resources> \ 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="ic_launcher_background">#FFC161</color> +</resources> \ 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. + --> +<resources> + <string name="app_name">JetLagged</string> + <string name="label_back">Back</string> + <string name="jetlagged_app_heading">JetLagged</string> + <string name="average_time_in_bed_heading">AVG. TIME IN BED</string> + <string name="average_sleep_time_heading">AVG. SLEEP TIME</string> + <string name="not_implemented">Not implemented yet</string> + <string name="placeholder_text_ave_time">8h2min</string> + <string name="placeholder_text_ave_time_2">7h15min</string> + <string name="sleep_type_awake">Awake</string> + <string name="sleep_type_rem">REM</string> + <string name="sleep_type_light">Light</string> + <string name="sleep_type_deep">Deep</string> + <string name="sleep_tab_day_heading">Day</string> + <string name="sleep_tab_week_heading">Week</string> + <string name="sleep_tab_month_heading">Month</string> + <string name="sleep_tab_six_months_heading">6M</string> + <string name="sleep_tab_one_year_heading">1Y</string> + <string name="heart_rate_heading">Heart Rate</string> + <string name="wellness_heading">Wellness</string> + <string name="snoring_heading">Snoring</string> + <string name="coughing_heading">Coughing</string> + <string name="respiration_heading">Respiration</string> + <string name="ave_time_sleep_heading">AVE TIME SLEEP</string> + <string name="ave_time_in_bed_heading">AVE TIME IN BED</string> + <string name="ambiance_heading">Ambiance</string> + <string name="room_temperature">Room Temperature</string> + + +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. + --> +<resources> + <style name="Theme.JetLagged" parent="android:Theme.Material.Light.NoActionBar"/> +</resources> diff --git a/JetLagged/build.gradle.kts b/JetLagged/build.gradle.kts new file mode 100644 index 0000000000..2b3f56e8a2 --- /dev/null +++ b/JetLagged/build.gradle.kts @@ -0,0 +1,73 @@ +/* + * 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.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 + alias(libs.plugins.spotless) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure<com.diffplug.gradle.spotless.SpotlessExtension> { + ratchetFrom = "origin/main" + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt") + ktlint().editorConfigOverride( + mapOf( + "ktlint_code_style" to "android_studio", + "ij_kotlin_allow_trailing_comma" to true, + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + // 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"), "(^(?![\\/ ]\\*).*$)") + } + kotlinGradle { + target("*.gradle.kts") + ktlint() + } + } +} diff --git a/JetLagged/buildscripts/toml-updater-config.gradle b/JetLagged/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/JetLagged/buildscripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/JetLagged/debug_2.keystore b/JetLagged/debug_2.keystore new file mode 100644 index 0000000000..b42c971788 Binary files /dev/null and b/JetLagged/debug_2.keystore differ diff --git a/JetLagged/gradle.properties b/JetLagged/gradle.properties new file mode 100644 index 0000000000..ed32c43142 --- /dev/null +++ b/JetLagged/gradle.properties @@ -0,0 +1,39 @@ +# +# Copyright 2022 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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/JetLagged/gradle/libs.versions.toml b/JetLagged/gradle/libs.versions.toml new file mode 100644 index 0000000000..29943df2e6 --- /dev/null +++ b/JetLagged/gradle/libs.versions.toml @@ -0,0 +1,177 @@ +##### +# This file is duplicated to individual samples from the global scripts/libs.versions.toml +# Do not add a dependency to an individual sample, edit the global version instead. +##### +[versions] +accompanist = "0.37.2" +android-material3 = "1.13.0-alpha13" +androidGradlePlugin = "8.9.2" +androidx-activity-compose = "1.10.1" +androidx-appcompat = "1.7.0" +androidx-compose-bom = "2025.04.01" +androidx-constraintlayout = "1.1.0" +androidx-core-splashscreen = "1.0.1" +androidx-corektx = "1.16.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.9" +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.2" +google-maps = "18.2.0" +gradle-versions = "0.52.0" +hilt = "2.56.2" +hiltExt = "1.2.0" +horologist = "0.6.23" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.1.20" +kotlinx-serialization-json = "1.7.3" +kotlinx_immutable = "0.3.8" +ksp = "2.1.20-2.0.0" +maps-compose = "3.1.1" +# @keep +minSdk = "21" +okhttp = "4.12.0" +play-services-wearable = "18.1.0" +robolectric = "4.14.1" +roborazzi = "1.43.1" +rome = "2.1.0" +room = "2.7.1" +secrets = "2.0.1" +spotless = "7.0.3" +# @keep +targetSdk = "33" +version-catalog-update = "1.0.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = "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" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/JetLagged/gradle/wrapper/gradle-wrapper.jar b/JetLagged/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..7454180f2a Binary files /dev/null and b/JetLagged/gradle/wrapper/gradle-wrapper.jar differ diff --git a/JetLagged/gradle/wrapper/gradle-wrapper.properties b/JetLagged/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..d6c8bc7bf8 --- /dev/null +++ b/JetLagged/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.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/JetLagged/gradlew b/JetLagged/gradlew new file mode 100755 index 0000000000..744e882ed5 --- /dev/null +++ b/JetLagged/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/JetLagged/gradlew.bat b/JetLagged/gradlew.bat new file mode 100644 index 0000000000..107acd32c4 --- /dev/null +++ b/JetLagged/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/JetLagged/screenshots/JetLagged_Full.png b/JetLagged/screenshots/JetLagged_Full.png new file mode 100644 index 0000000000..9229bf48a4 Binary files /dev/null and b/JetLagged/screenshots/JetLagged_Full.png differ diff --git a/JetLagged/screenshots/screenshots.png b/JetLagged/screenshots/screenshots.png new file mode 100644 index 0000000000..d407afbc5a Binary files /dev/null and b/JetLagged/screenshots/screenshots.png differ diff --git a/JetLagged/settings.gradle.kts b/JetLagged/settings.gradle.kts new file mode 100644 index 0000000000..ac6710c288 --- /dev/null +++ b/JetLagged/settings.gradle.kts @@ -0,0 +1,38 @@ +/* + * 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 = "JetLagged" +include(":app") diff --git a/Crane/spotless/copyright.kt b/JetLagged/spotless/copyright.kt similarity index 100% rename from Crane/spotless/copyright.kt rename to JetLagged/spotless/copyright.kt diff --git a/JetNews/.gitignore b/JetNews/.gitignore index f7415cfd67..834ecd9dff 100644 --- a/JetNews/.gitignore +++ b/JetNews/.gitignore @@ -12,4 +12,5 @@ /captures .externalNativeBuild .cxx -local.properties \ No newline at end of file +local.properties +.kotlin/ diff --git a/JetNews/.google/packaging.yaml b/JetNews/.google/packaging.yaml index 9d138a236b..0749d4e4b7 100644 --- a/JetNews/.google/packaging.yaml +++ b/JetNews/.google/packaging.yaml @@ -18,10 +18,20 @@ # End users may safely ignore this file. It has no relevance to other systems. --- status: PUBLISHED -technologies: [Android] -categories: [Compose] +technologies: [Android, JetpackCompose] +categories: + - AndroidArchitectureUILayer + - AndroidArchitectureStateProduction + - JetpackComposeArchitectureAndState + - JetpackComposeDesignSystems + - JetpackComposeLayouts + - JetpackComposeNavigation + - JetpackComposeTesting languages: [Kotlin] -solutions: [Mobile] +solutions: + - Mobile + - JetpackLifecycle + - JetpackNavigation github: android/compose-samples level: INTERMEDIATE apiRefs: diff --git a/JetNews/.run/Instrumented tests.run.xml b/JetNews/.run/Instrumented tests.run.xml new file mode 100644 index 0000000000..96620befc4 --- /dev/null +++ b/JetNews/.run/Instrumented tests.run.xml @@ -0,0 +1,53 @@ +<component name="ProjectRunConfigurationManager"> + <configuration default="false" name="Instrumented tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests"> + <module name="JetNews.app" /> + <option name="TESTING_TYPE" value="1" /> + <option name="METHOD_NAME" value="" /> + <option name="CLASS_NAME" value="" /> + <option name="PACKAGE_NAME" value="com.example.jetnews" /> + <option name="INSTRUMENTATION_RUNNER_CLASS" value="" /> + <option name="EXTRA_OPTIONS" value="" /> + <option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" /> + <option name="CLEAR_LOGCAT" value="false" /> + <option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" /> + <option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" /> + <option name="FORCE_STOP_RUNNING_APP" value="true" /> + <option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" /> + <option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" /> + <option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" /> + <option name="DEBUGGER_TYPE" value="Auto" /> + <Auto> + <option name="USE_JAVA_AWARE_DEBUGGER" value="false" /> + <option name="SHOW_STATIC_VARS" value="true" /> + <option name="WORKING_DIR" value="" /> + <option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" /> + <option name="SHOW_OPTIMIZED_WARNING" value="true" /> + </Auto> + <Hybrid> + <option name="USE_JAVA_AWARE_DEBUGGER" value="false" /> + <option name="SHOW_STATIC_VARS" value="true" /> + <option name="WORKING_DIR" value="" /> + <option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" /> + <option name="SHOW_OPTIMIZED_WARNING" value="true" /> + </Hybrid> + <Java /> + <Native> + <option name="USE_JAVA_AWARE_DEBUGGER" value="false" /> + <option name="SHOW_STATIC_VARS" value="true" /> + <option name="WORKING_DIR" value="" /> + <option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" /> + <option name="SHOW_OPTIMIZED_WARNING" value="true" /> + </Native> + <Profilers> + <option name="ADVANCED_PROFILING_ENABLED" value="false" /> + <option name="STARTUP_PROFILING_ENABLED" value="false" /> + <option name="STARTUP_CPU_PROFILING_ENABLED" value="false" /> + <option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" /> + <option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" /> + <option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" /> + </Profilers> + <method v="2"> + <option name="Android.Gradle.BeforeRunTask" enabled="true" /> + </method> + </configuration> +</component> \ No newline at end of file diff --git a/JetNews/.run/Robolectric tests.run.xml b/JetNews/.run/Robolectric tests.run.xml new file mode 100644 index 0000000000..728fefb964 --- /dev/null +++ b/JetNews/.run/Robolectric tests.run.xml @@ -0,0 +1,24 @@ +<component name="ProjectRunConfigurationManager"> + <configuration default="false" name="Robolectric tests" type="GradleRunConfiguration" factoryName="Gradle"> + <ExternalSystemSettings> + <option name="executionName" /> + <option name="externalProjectPath" value="$PROJECT_DIR$/app" /> + <option name="externalSystemIdString" value="GRADLE" /> + <option name="scriptParameters" value="--tests "com.example.jetnews.*"" /> + <option name="taskDescriptions"> + <list /> + </option> + <option name="taskNames"> + <list> + <option value=":app:cleanTestDebugUnitTest" /> + <option value=":app:testDebugUnitTest" /> + </list> + </option> + <option name="vmOptions" value="" /> + </ExternalSystemSettings> + <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess> + <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess> + <DebugAllEnabled>false</DebugAllEnabled> + <method v="2" /> + </configuration> +</component> \ No newline at end of file diff --git a/JetNews/README.md b/JetNews/README.md index f07b3de156..1bb270768b 100644 --- a/JetNews/README.md +++ b/JetNews/README.md @@ -3,14 +3,15 @@ Jetnews is a sample news reading app, built with [Jetpack Compose](https://developer.android.com/jetpack/compose). The goal of the sample is to showcase the current UI capabilities of Compose. -To try out this sample app, you need to use the latest Canary version of Android Studio 4.2. +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). You can clone this repository or import the project from Android Studio following the steps [here](https://developer.android.com/jetpack/compose/setup#sample). -Screenshots ------------ -<img src="screenshots/jetnews_demo.gif" alt="Screenshot"> +## Screenshots + +<img src="screenshots/screenshots.png" alt="Screenshot"> ## Features @@ -22,15 +23,17 @@ screen uses a navigation drawer. Package [`com.example.jetnews.ui`][1] -[`JetnewsApp.kt`][2] arranges the different screens in the `NavDrawerLayout`. It also implements a simple -navigation pattern. +[`JetnewsApp.kt`][2] arranges the different screens in the `NavDrawerLayout`. + +[`JetnewsNavGraph.kt`][3] configures the navigation routes and actions in the app. [1]: app/src/main/java/com/example/jetnews/ui [2]: app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt +[3]: app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt ### Main article list -Package [`com.example.jetnews.ui.home`][3] +Package [`com.example.jetnews.ui.home`][4] This screen shows how to create different custom Composable functions and combine them in a list that scrolls vertically and horizontally. @@ -38,49 +41,82 @@ that scrolls vertically and horizontally. See how to: * Use `Row`s and `Column`s to arrange the contents of the UI -* Add an `AppBar` -* Use `MaterialTypography` and opacity to style the text -* Use `Shape` to round the corners of the images -* Use elevation to make the `Card`s stand out from the background +* Add a top app bar that elevates as the user scrolls +* Use Material's `Typography` and `ColorScheme` to style the text +* Use tonal elevation to make the `Card`s stand out from the background -[3]: app/src/main/java/com/example/jetnews/ui/home +[4]: app/src/main/java/com/example/jetnews/ui/home ### Article detail -Package [`com.example.jetnews.ui.article`][4] +Package [`com.example.jetnews.ui.article`][5] This screen dives into the Text API, showing how to use different fonts than the ones defined in -[`Typograhy`][5]. It also adds a bottom appbar, with custom actions. +[`Typography`][6]. It also adds a bottom app bar, with custom actions. -[4]: app/src/main/java/com/example/jetnews/ui/article -[5]: app/src/main/java/com/example/jetnews/ui/theme/Type.kt +[5]: app/src/main/java/com/example/jetnews/ui/article +[6]: app/src/main/java/com/example/jetnews/ui/theme/Type.kt ### Interests screen -Package [`com.example.jetnews.ui.interests`][6] +Package [`com.example.jetnews.ui.interests`][7] This screens shows how to use Tabs and switch content depending on the selected tab. It -also includes a custom checkbox button, [SelectTopicButton][7] +also includes a custom checkbox button, [SelectTopicButton][8] that uses a `Toggleable` composable function to provide the on/off behaviour and semantics, while drawing a custom UI. The UI of the button is partly drawn with low-level primitives and partly overlaying images. See also how to visualize on and off, light and dark version in the Android Studio Preview. -[6]: app/src/main/java/com/example/jetnews/ui/interests -[7]: app/src/main/java/com/example/jetnews/ui/interests/SelectTopicButton.kt +[7]: app/src/main/java/com/example/jetnews/ui/interests +[8]: app/src/main/java/com/example/jetnews/ui/interests/SelectTopicButton.kt + +### AppWidget powered by Glance + +Package [`com.example.jetnews.glance`][9] + +This package shows how to use Glance and write compose style code for AppWidgets. + +See how to: +* Use `Row`, `Column`, `LazyColumn` to arrange the contents of the UI +* Use a repository from your existing app to load data for the widget and perform updates +* Configure `android:updatePeriodMillis` to periodically refresh the widget +* Use `androidx.glance:glance-material3` library to create a custom color scheme with `GlanceTheme` +and use dynamic colors when supported +* Tint `Image`s to match the color scheme +* Launch an activity on click using `actionStartActivity` + +[9]: app/src/main/java/com/example/jetnews/glance ### Data The data in the sample is static, held in the `com.example.jetnews.data` package. -### UI testing +### Instrumented and Robolectric tests + +UI tests can be run on device/emulators or on JVM with Robolectric. + +* To run Instrumented tests use the "Instrumented tests" run configuration or run the `./gradlew connectedCheck` command. +* To run tests with Robolectric use the "Robolectric tests" run configuration or run the `./gradlew testDebug` command. + +## Jetnews for every screen + +<img src="screenshots/jetnews_all_screens.png" alt="Screenshot"> + +We recently updated Jetnews to enhance its behavior across all mobile devices, both big and small. +Jetnews already had support for “traditional” mobile screens, so it was tempting to describe all of +our changes as “adding large screen support.” While that is true, it misses the point of having +adaptive UI. For example, if your app is running in split screen mode on a tablet, it shouldn't try +to display “tablet UI” unless it actually has enough space for it. With all of these changes, +Jetnews is working better than ever on large screens, but also on small screens too. -Run UI tests from Android Studio or with the `./gradlew connectedCheck` command. +Check out the blog post that explains all the changes in more details: +https://medium.com/androiddevelopers/jetnews-for-every-screen-4d8e7927752 ## License ``` -Copyright 2020 The Android Open Source Project +Copyright 2021 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/JetNews/app/build.gradle b/JetNews/app/build.gradle deleted file mode 100644 index 5b5a909c2b..0000000000 --- a/JetNews/app/build.gradle +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' - -android { - compileSdkVersion 30 - defaultConfig { - applicationId 'com.example.jetnews' - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName '1.0' - vectorDrawables.useSupportLibrary = true - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - allWarningsAsErrors = true - } - - buildFeatures { - compose true - } - - composeOptions { - kotlinCompilerVersion kotlin_version - kotlinCompilerExtensionVersion compose_version - } - - packagingOptions { - excludes += "/META-INF/AL2.0" - excludes += "/META-INF/LGPL2.1" - } -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" - - implementation "androidx.compose.runtime:runtime:$compose_version" - implementation "androidx.compose.ui:ui:$compose_version" - implementation "androidx.compose.foundation:foundation-layout:$compose_version" - implementation "androidx.compose.material:material:$compose_version" - implementation "androidx.compose.material:material-icons-extended:$compose_version" - implementation "androidx.compose.foundation:foundation:$compose_version" - implementation "androidx.compose.animation:animation:$compose_version" - implementation "androidx.compose.ui:ui-tooling:$compose_version" - implementation "androidx.compose.runtime:runtime-livedata:$compose_version" - - implementation 'androidx.appcompat:appcompat:1.3.0-alpha02' - implementation 'androidx.activity:activity-ktx:1.1.0' - implementation 'androidx.core:core-ktx:1.5.0-alpha05' - - implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.0-rc01" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-rc01" - - androidTestImplementation 'junit:junit:4.13.1' - androidTestImplementation 'androidx.test:rules:1.3.0' - androidTestImplementation 'androidx.test:runner:1.3.0' - androidTestImplementation "androidx.compose.ui:ui-test:$compose_version" - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" -} - -tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - // Enable experimental coroutines APIs, including Flow - freeCompilerArgs += '-Xopt-in=kotlin.Experimental' - freeCompilerArgs += '-Xallow-jvm-ir-dependencies' - - // Set JVM target to 1.8 - jvmTarget = "1.8" - } -} diff --git a/JetNews/app/build.gradle.kts b/JetNews/app/build.gradle.kts new file mode 100644 index 0000000000..f9552acad3 --- /dev/null +++ b/JetNews/app/build.gradle.kts @@ -0,0 +1,128 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.compose) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "com.example.jetnews" + + defaultConfig { + applicationId = "com.example.jetnews" + 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") { + 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 + } + + packaging.resources { + // Multiple dependency bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.materialWindow) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + + implementation(libs.androidx.glance) + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.material3) + + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.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) +} + diff --git a/JetNews/app/proguard-rules.pro b/JetNews/app/proguard-rules.pro index 4cb94585a0..f8f76182de 100644 --- a/JetNews/app/proguard-rules.pro +++ b/JetNews/app/proguard-rules.pro @@ -22,3 +22,16 @@ # Repackage classes into the top-level. -repackageclasses + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE + +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } \ No newline at end of file diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/HomeScreenSnackbarTest.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/HomeScreenSnackbarTest.kt deleted file mode 100644 index 910bc85b3f..0000000000 --- a/JetNews/app/src/androidTest/java/com/example/jetnews/HomeScreenSnackbarTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetnews - -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.ExperimentalComposeApi -import androidx.compose.runtime.snapshots.snapshotFlow -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.test.platform.app.InstrumentationRegistry -import com.example.jetnews.ui.home.HomeScreen -import com.example.jetnews.ui.state.UiState -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.Rule -import org.junit.Test - -/** - * Checks that the Snackbar is shown when the HomeScreen data contains an error. - */ -class HomeScreenSnackbarTest { - - @get:Rule - val composeTestRule = createComposeRule() - - @OptIn( - ExperimentalMaterialApi::class, - ExperimentalComposeApi::class - ) - @Test - fun postsContainError_snackbarShown() { - val snackbarHostState = SnackbarHostState() - composeTestRule.setContent { - val scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState) - - // When the Home screen receives data with an error - HomeScreen( - posts = UiState(exception = IllegalStateException()), - favorites = emptySet(), - onToggleFavorite = {}, - onRefreshPosts = {}, - onErrorDismiss = {}, - navigateTo = {}, - scaffoldState = scaffoldState - ) - } - - // Then the first message received in the Snackbar is an error message - val snackbarText = InstrumentationRegistry.getInstrumentation() - .targetContext.resources.getString(R.string.load_error) - runBlocking { - // snapshotFlow converts a State to a Kotlin Flow so we can observe it - // wait for the first a non-null `currentSnackbarData` - snapshotFlow { snackbarHostState.currentSnackbarData }.filterNotNull().first() - composeTestRule.onNodeWithText(snackbarText, false, false).assertIsDisplayed() - } - } -} diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/HomeScreenTests.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/HomeScreenTests.kt new file mode 100644 index 0000000000..5d1432ad0d --- /dev/null +++ b/JetNews/app/src/androidTest/java/com/example/jetnews/HomeScreenTests.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetnews + +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.example.jetnews.ui.home.HomeFeedScreen +import com.example.jetnews.ui.home.HomeUiState +import com.example.jetnews.ui.theme.JetnewsTheme +import com.example.jetnews.utils.ErrorMessage +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeScreenTests { + + @get:Rule + val composeTestRule = createComposeRule() + + /** + * Checks that the Snackbar is shown when the HomeScreen data contains an error. + */ + @Test + fun postsContainError_snackbarShown() { + val snackbarHostState = SnackbarHostState() + composeTestRule.setContent { + JetnewsTheme { + + // When the Home screen receives data with an error + HomeFeedScreen( + uiState = HomeUiState.NoPosts( + isLoading = false, + errorMessages = listOf(ErrorMessage(0L, R.string.load_error)), + searchInput = "" + ), + showTopAppBar = false, + onToggleFavorite = {}, + onSelectPost = {}, + onRefreshPosts = {}, + onErrorDismiss = {}, + openDrawer = {}, + homeListLazyListState = rememberLazyListState(), + snackbarHostState = snackbarHostState, + onSearchInputChanged = {} + ) + } + } + + // Then the first message received in the Snackbar is an error message + runBlocking { + // snapshotFlow converts a State to a Kotlin Flow so we can observe it + // wait for the first a non-null `currentSnackbarData` + val actualSnackbarText = snapshotFlow { snackbarHostState.currentSnackbarData } + .filterNotNull().first().visuals.message + val expectedSnackbarText = InstrumentationRegistry.getInstrumentation() + .targetContext.resources.getString(R.string.load_error) + assertEquals(expectedSnackbarText, actualSnackbarText) + } + } +} diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt new file mode 100644 index 0000000000..bd02498cdd --- /dev/null +++ b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt @@ -0,0 +1,80 @@ +/* + * 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.jetnews + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.ExperimentalTestApi +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.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.printToString +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.jetnews.data.posts.impl.manuel +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalTestApi::class) +@RunWith(AndroidJUnit4::class) +class JetnewsTests { + + @get:Rule + val composeTestRule = createAndroidComposeRule<ComponentActivity>() + + @Before + fun setUp() { + // Using targetContext as the Context of the instrumentation code + composeTestRule.launchJetNewsApp(ApplicationProvider.getApplicationContext()) + } + + @Test + fun app_launches() { + composeTestRule + .onNodeWithText(composeTestRule.activity.getString(R.string.home_top_section_title)) + .assertExists() + } + + @Test + fun app_opensArticle() { + + println(composeTestRule.onRoot().printToString()) + composeTestRule.onAllNodes(hasText(manuel.name, substring = true))[0].performClick() + + println(composeTestRule.onRoot().printToString()) + try { + composeTestRule.onAllNodes(hasText("3 min read", substring = true))[0].assertExists() + } catch (e: AssertionError) { + println(composeTestRule.onRoot().printToString()) + throw e + } + } + + @Test + fun app_opensInterests() { + composeTestRule.onNodeWithContentDescription( + label = "Open navigation drawer", + useUnmergedTree = true + ).performClick() + composeTestRule.onNodeWithText("Interests").performClick() + composeTestRule.waitUntilAtLeastOneExists(hasText("Topics"), 5000L) + } +} diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsUiTest.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsUiTest.kt deleted file mode 100644 index 59eb2ef3bc..0000000000 --- a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsUiTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetnews - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasSubstring -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.filters.MediumTest -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Before -import org.junit.Ignore -import org.junit.Rule -import org.junit.Test - -@MediumTest -class JetnewsUiTest { - - @get:Rule - val composeTestRule = createComposeRule() - - @Before - fun setUp() { - // Using targetContext as the Context of the instrumentation code - composeTestRule.launchJetNewsApp(InstrumentationRegistry.getInstrumentation().targetContext) - } - - @Ignore("TODO Investigate why this passes locally but fail on CI") - @Test - fun app_launches() { - composeTestRule.onNodeWithText("Jetnews").assertIsDisplayed() - } - - @Ignore("TODO Investigate why this passes locally but fail on CI") - @Test - fun app_opensArticle() { - composeTestRule.onAllNodes(hasSubstring("Manuel Vivo"))[0].performClick() - composeTestRule.onAllNodes(hasSubstring("3 min read"))[0].assertIsDisplayed() - } -} diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/TestAppContainer.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/TestAppContainer.kt index 0131fa4fa5..2b8ed36c6c 100644 --- a/JetNews/app/src/androidTest/java/com/example/jetnews/TestAppContainer.kt +++ b/JetNews/app/src/androidTest/java/com/example/jetnews/TestAppContainer.kt @@ -26,7 +26,7 @@ import com.example.jetnews.data.posts.impl.BlockingFakePostsRepository class TestAppContainer(private val context: Context) : AppContainer { override val postsRepository: PostsRepository by lazy { - BlockingFakePostsRepository(context) + BlockingFakePostsRepository() } override val interestsRepository: InterestsRepository by lazy { diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt index e704c7bcb3..1e7e115d3b 100644 --- a/JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt +++ b/JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt @@ -17,20 +17,18 @@ package com.example.jetnews import android.content.Context -import androidx.compose.runtime.remember -import androidx.compose.ui.test.junit4.ComposeTestRule -import androidx.lifecycle.SavedStateHandle +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.ui.test.junit4.ComposeContentTestRule import com.example.jetnews.ui.JetnewsApp -import com.example.jetnews.ui.NavigationViewModel /** * Launches the app from a test context */ -fun ComposeTestRule.launchJetNewsApp(context: Context) { +fun ComposeContentTestRule.launchJetNewsApp(context: Context) { setContent { JetnewsApp( - TestAppContainer(context), - remember { NavigationViewModel(SavedStateHandle()) } + appContainer = TestAppContainer(context), + widthSizeClass = WindowWidthSizeClass.Compact ) } } diff --git a/JetNews/app/src/main/AndroidManifest.xml b/JetNews/app/src/main/AndroidManifest.xml index e188e4790a..134af83d20 100644 --- a/JetNews/app/src/main/AndroidManifest.xml +++ b/JetNews/app/src/main/AndroidManifest.xml @@ -12,27 +12,46 @@ or implied. See the License for the specific language governing permissions and limitations under the License. --> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.example.jetnews"> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <application android:name=".JetnewsApplication" android:allowBackup="true" + android:enableOnBackInvokedCallback="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.Jetnews"> - <activity android:name=".ui.MainActivity"> + + <!-- adjustResize ensures that the main window resizes to make room for the soft keyboard--> + <activity + android:name=".ui.MainActivity" + android:exported="true" + android:windowSoftInputMode="adjustResize"> <intent-filter> <action android:name="android.intent.action.MAIN" /> - <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.BROWSABLE"/> + <data + android:host="developer.android.com" + android:pathPrefix="/jetnews" + android:scheme="https" /> + </intent-filter> </activity> - <!-- Needed for UI tests --> - <activity - android:name="androidx.activity.ComponentActivity" - android:theme="@style/Theme.Jetnews" /> + <receiver + android:name=".glance.JetnewsGlanceAppWidgetReceiver" + android:exported="false" + android:enabled="@bool/glance_appwidget_available"> + <intent-filter> + <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> + </intent-filter> + <meta-data + android:name="android.appwidget.provider" + android:resource="@xml/jetnews_glance_appwidget_info" /> + </receiver> </application> </manifest> diff --git a/JetNews/app/src/main/java/com/example/jetnews/JetnewsApplication.kt b/JetNews/app/src/main/java/com/example/jetnews/JetnewsApplication.kt index 44721f1da4..f248317c44 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/JetnewsApplication.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/JetnewsApplication.kt @@ -21,6 +21,9 @@ import com.example.jetnews.data.AppContainer import com.example.jetnews.data.AppContainerImpl class JetnewsApplication : Application() { + companion object { + const val JETNEWS_APP_URI = "https://developer.android.com/jetnews" + } // AppContainer instance used by the rest of classes to obtain dependencies lateinit var container: AppContainer diff --git a/JetNews/app/src/main/java/com/example/jetnews/data/AppContainerImpl.kt b/JetNews/app/src/main/java/com/example/jetnews/data/AppContainerImpl.kt index 881eca2655..ed5aaeda92 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/data/AppContainerImpl.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/data/AppContainerImpl.kt @@ -38,9 +38,7 @@ interface AppContainer { class AppContainerImpl(private val applicationContext: Context) : AppContainer { override val postsRepository: PostsRepository by lazy { - FakePostsRepository( - resources = applicationContext.resources - ) + FakePostsRepository() } override val interestsRepository: InterestsRepository by lazy { diff --git a/JetNews/app/src/main/java/com/example/jetnews/data/Result.kt b/JetNews/app/src/main/java/com/example/jetnews/data/Result.kt index 6a8ed26543..9273b0b02e 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/data/Result.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/data/Result.kt @@ -20,17 +20,10 @@ package com.example.jetnews.data * A generic class that holds a value or an exception */ sealed class Result<out R> { - data class Success<out T>(val data: T) : Result<T>() data class Error(val exception: Exception) : Result<Nothing>() } -/** - * `true` if [Result] is of type [Success] & holds non-null [Success.data]. - */ -val Result<*>.succeeded - get() = this is Result.Success && data != null - fun <T> Result<T>.successOr(fallback: T): T { return (this as? Result.Success<T>)?.data ?: fallback } diff --git a/JetNews/app/src/main/java/com/example/jetnews/data/interests/InterestsRepository.kt b/JetNews/app/src/main/java/com/example/jetnews/data/interests/InterestsRepository.kt index 82f108de8c..9b4ffb1774 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/data/interests/InterestsRepository.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/data/interests/InterestsRepository.kt @@ -19,7 +19,7 @@ package com.example.jetnews.data.interests import com.example.jetnews.data.Result import kotlinx.coroutines.flow.Flow -typealias TopicsMap = Map<String, List<String>> +data class InterestSection(val title: String, val interests: List<String>) /** * Interface to the Interests data layer. @@ -29,7 +29,7 @@ interface InterestsRepository { /** * Get relevant topics to the user. */ - suspend fun getTopics(): Result<TopicsMap> + suspend fun getTopics(): Result<List<InterestSection>> /** * Get list of people. diff --git a/JetNews/app/src/main/java/com/example/jetnews/data/interests/impl/FakeInterestsRepository.kt b/JetNews/app/src/main/java/com/example/jetnews/data/interests/impl/FakeInterestsRepository.kt index 31892052d3..2f7750740c 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/data/interests/impl/FakeInterestsRepository.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/data/interests/impl/FakeInterestsRepository.kt @@ -17,15 +17,14 @@ package com.example.jetnews.data.interests.impl import com.example.jetnews.data.Result +import com.example.jetnews.data.interests.InterestSection import com.example.jetnews.data.interests.InterestsRepository import com.example.jetnews.data.interests.TopicSelection -import com.example.jetnews.data.interests.TopicsMap import com.example.jetnews.utils.addOrRemove import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.flow.update /** * Implementation of InterestRepository that returns a hardcoded list of @@ -35,10 +34,13 @@ import kotlinx.coroutines.sync.withLock class FakeInterestsRepository : InterestsRepository { private val topics by lazy { - mapOf( - "Android" to listOf("Jetpack Compose", "Kotlin", "Jetpack"), - "Programming" to listOf("Kotlin", "Declarative UIs", "Java"), - "Technology" to listOf("Pixel", "Google") + listOf( + InterestSection("Android", listOf("Jetpack Compose", "Kotlin", "Jetpack")), + InterestSection( + "Programming", + listOf("Kotlin", "Declarative UIs", "Java", "Unidirectional Data Flow", "C++") + ), + InterestSection("Technology", listOf("Pixel", "Google")) ) } @@ -75,10 +77,7 @@ class FakeInterestsRepository : InterestsRepository { private val selectedPeople = MutableStateFlow(setOf<String>()) private val selectedPublications = MutableStateFlow(setOf<String>()) - // Used to make suspend functions that read and update state safe to call from any thread - private val mutex = Mutex() - - override suspend fun getTopics(): Result<TopicsMap> { + override suspend fun getTopics(): Result<List<InterestSection>> { return Result.Success(topics) } @@ -91,26 +90,20 @@ class FakeInterestsRepository : InterestsRepository { } override suspend fun toggleTopicSelection(topic: TopicSelection) { - mutex.withLock { - val set = selectedTopics.value.toMutableSet() - set.addOrRemove(topic) - selectedTopics.value = set + selectedTopics.update { + it.addOrRemove(topic) } } override suspend fun togglePersonSelected(person: String) { - mutex.withLock { - val set = selectedPeople.value.toMutableSet() - set.addOrRemove(person) - selectedPeople.value = set + selectedPeople.update { + it.addOrRemove(person) } } override suspend fun togglePublicationSelected(publication: String) { - mutex.withLock { - val set = selectedPublications.value.toMutableSet() - set.addOrRemove(publication) - selectedPublications.value = set + selectedPublications.update { + it.addOrRemove(publication) } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/data/posts/PostsRepository.kt b/JetNews/app/src/main/java/com/example/jetnews/data/posts/PostsRepository.kt index 343f100df6..d880a36e77 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/data/posts/PostsRepository.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/data/posts/PostsRepository.kt @@ -18,6 +18,7 @@ package com.example.jetnews.data.posts import com.example.jetnews.data.Result import com.example.jetnews.model.Post +import com.example.jetnews.model.PostsFeed import kotlinx.coroutines.flow.Flow /** @@ -28,18 +29,23 @@ interface PostsRepository { /** * Get a specific JetNews post. */ - suspend fun getPost(postId: String): Result<Post> + suspend fun getPost(postId: String?): Result<Post> /** * Get JetNews posts. */ - suspend fun getPosts(): Result<List<Post>> + suspend fun getPostsFeed(): Result<PostsFeed> /** * Observe the current favorites */ fun observeFavorites(): Flow<Set<String>> + /** + * Observe the posts feed. + */ + fun observePostsFeed(): Flow<PostsFeed?> + /** * Toggle a postId to be a favorite or not. */ diff --git a/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/BlockingFakePostsRepository.kt b/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/BlockingFakePostsRepository.kt index ff1e595952..aa95a36d69 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/BlockingFakePostsRepository.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/BlockingFakePostsRepository.kt @@ -16,16 +16,16 @@ package com.example.jetnews.data.posts.impl -import android.content.Context -import androidx.compose.ui.graphics.imageFromResource import com.example.jetnews.data.Result import com.example.jetnews.data.posts.PostsRepository import com.example.jetnews.model.Post +import com.example.jetnews.model.PostsFeed import com.example.jetnews.utils.addOrRemove import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext /** @@ -33,23 +33,16 @@ import kotlinx.coroutines.withContext * posts with resources synchronously. */ @OptIn(ExperimentalCoroutinesApi::class) -class BlockingFakePostsRepository(private val context: Context) : PostsRepository { - - private val postsWithResources: List<Post> by lazy { - posts.map { - it.copy( - image = imageFromResource(context.resources, it.imageId), - imageThumb = imageFromResource(context.resources, it.imageThumbId) - ) - } - } +class BlockingFakePostsRepository : PostsRepository { // for now, keep the favorites in memory private val favorites = MutableStateFlow<Set<String>>(setOf()) - override suspend fun getPost(postId: String): Result<Post> { + private val postsFeed = MutableStateFlow<PostsFeed?>(null) + + override suspend fun getPost(postId: String?): Result<Post> { return withContext(Dispatchers.IO) { - val post = postsWithResources.find { it.id == postId } + val post = posts.allPosts.find { it.id == postId } if (post == null) { Result.Error(IllegalArgumentException("Unable to find post")) } else { @@ -58,15 +51,15 @@ class BlockingFakePostsRepository(private val context: Context) : PostsRepositor } } - override suspend fun getPosts(): Result<List<Post>> { - return Result.Success(postsWithResources) + override suspend fun getPostsFeed(): Result<PostsFeed> { + postsFeed.update { posts } + return Result.Success(posts) } override fun observeFavorites(): Flow<Set<String>> = favorites + override fun observePostsFeed(): Flow<PostsFeed?> = postsFeed override suspend fun toggleFavorite(postId: String) { - val set = favorites.value.toMutableSet() - set.addOrRemove(postId) - favorites.value = set + favorites.update { it.addOrRemove(postId) } } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/FakePostsRepository.kt b/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/FakePostsRepository.kt index 357dea10db..939e095a1b 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/FakePostsRepository.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/FakePostsRepository.kt @@ -16,54 +16,34 @@ package com.example.jetnews.data.posts.impl -import android.content.res.Resources -import androidx.compose.ui.graphics.imageFromResource import com.example.jetnews.data.Result import com.example.jetnews.data.posts.PostsRepository import com.example.jetnews.model.Post +import com.example.jetnews.model.PostsFeed import com.example.jetnews.utils.addOrRemove import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext /** * Implementation of PostsRepository that returns a hardcoded list of * posts with resources after some delay in a background thread. */ -@OptIn(ExperimentalCoroutinesApi::class) -class FakePostsRepository( - private val resources: Resources -) : PostsRepository { - - /** - * Simulates preparing the data for each post. - * - * DISCLAIMER: Loading resources with the ApplicationContext isn't ideal as it isn't themed. - * This should be done from the UI layer. - */ - private val postsWithResources: List<Post> by lazy { - posts.map { - it.copy( - image = imageFromResource(resources, it.imageId), - imageThumb = imageFromResource(resources, it.imageThumbId) - ) - } - } +class FakePostsRepository : PostsRepository { // for now, store these in memory private val favorites = MutableStateFlow<Set<String>>(setOf()) + private val postsFeed = MutableStateFlow<PostsFeed?>(null) + // Used to make suspend functions that read and update state safe to call from any thread - private val mutex = Mutex() - override suspend fun getPost(postId: String): Result<Post> { + override suspend fun getPost(postId: String?): Result<Post> { return withContext(Dispatchers.IO) { - val post = postsWithResources.find { it.id == postId } + val post = posts.allPosts.find { it.id == postId } if (post == null) { Result.Error(IllegalArgumentException("Post not found")) } else { @@ -72,24 +52,24 @@ class FakePostsRepository( } } - override suspend fun getPosts(): Result<List<Post>> { + override suspend fun getPostsFeed(): Result<PostsFeed> { return withContext(Dispatchers.IO) { delay(800) // pretend we're on a slow network if (shouldRandomlyFail()) { Result.Error(IllegalStateException()) } else { - Result.Success(postsWithResources) + postsFeed.update { posts } + Result.Success(posts) } } } override fun observeFavorites(): Flow<Set<String>> = favorites + override fun observePostsFeed(): Flow<PostsFeed?> = postsFeed override suspend fun toggleFavorite(postId: String) { - mutex.withLock { - val set = favorites.value.toMutableSet() - set.addOrRemove(postId) - favorites.value = set.toSet() + favorites.update { + it.addOrRemove(postId) } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/PostsData.kt b/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/PostsData.kt index d00452faf4..862fc03b27 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/PostsData.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/data/posts/impl/PostsData.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * 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. @@ -14,10 +14,9 @@ * limitations under the License. */ +@file:Suppress("ktlint:max-line-length") // String constants read better package com.example.jetnews.data.posts.impl -import android.content.res.Resources -import androidx.compose.ui.graphics.imageFromResource import com.example.jetnews.R import com.example.jetnews.model.Markup import com.example.jetnews.model.MarkupType @@ -26,6 +25,7 @@ import com.example.jetnews.model.Paragraph import com.example.jetnews.model.ParagraphType import com.example.jetnews.model.Post import com.example.jetnews.model.PostAuthor +import com.example.jetnews.model.PostsFeed import com.example.jetnews.model.Publication /** @@ -41,6 +41,9 @@ val florina = PostAuthor( val jose = PostAuthor("Jose Alcérreca", "https://medium.com/@JoseAlcerreca") +val androidstudioteam = + PostAuthor("Android Studio Team", "https://twitter.com/androidstudio") + val publication = Publication( "Android Developers", "https://cdn-images-1.medium.com/max/258/1*u7oZc2_5mrkcFaxkXEyfYA@2x.png" @@ -714,7 +717,7 @@ val paragraphsPost5 = listOf( ParagraphType.Text, "Sequences are lazily evaluated. They have two types of operations: intermediate and terminal. Intermediate operations are not performed on the spot; they’re just stored. Only when a terminal operation is called, the intermediate operations are triggered on each element in a row and finally, the terminal operation is applied. Intermediate operations (like map, distinct, groupBy etc) return another sequence whereas terminal operations (like first, toList, count etc) don’t.", listOf( - Markup(MarkupType.Code, 357, 3600), + Markup(MarkupType.Code, 357, 360), Markup(MarkupType.Code, 362, 370), Markup(MarkupType.Code, 372, 379), Markup(MarkupType.Code, 443, 448), @@ -934,6 +937,153 @@ val paragraphsPost5 = listOf( ) ) +val paragraphsPost6 = listOf( + Paragraph( + ParagraphType.Text, + "The Android Studio logo redesign caught the attention of the developer community since its sneak peek at the Android Developer Summit. We are thrilled to release the new Android Studio logo with the stable release of Flamingo. Now that the new logo is available to most Android Studio users, we can examine the design changes in greater detail and decode their meaning." + ), + Paragraph( + ParagraphType.Text, + "This case study offers a comprehensive overview of the design journey, from identifying the initial problem to the final outcome. It explores the critical brand elements that the team needed to consider and the tools used throughout the redesign process. This case study also delves into the various stages of design exploration, highlighting the efforts to create a modern logo while honoring the Android Studio brand's legacy." + ), + Paragraph( + ParagraphType.Header, + "Identifying the problem" + ), + Paragraph( + ParagraphType.Text, + "You told us the Android Studio logo looked a little weird and complicated. It doesn't shrink down well and it's way too similar to the emulator. We heard you!" + ), + Paragraph( + ParagraphType.Text, + "The Android Studio logo used between 2020 and 2022 was well-suited for print, but it posed challenges when used as an application icon. Its readability suffered when reduced to smaller sizes, and its similarity to the emulator caused confusion." + ), + Paragraph( + ParagraphType.Text, + "Additionally, the use of color alone to differentiate between Canary and Stable versions made it difficult for users with color vision deficiencies." + ), + Paragraph( + ParagraphType.Text, + "The redesign aimed to resolve these concerns by creating a logo that was easy to read, visually distinctive, and followed the OS guidelines when necessary, ensuring accessibility. The new design also maintained a connection with the Android logo family while honoring its legacy." + ), + Paragraph( + ParagraphType.Text, + "In this case study, we will delve into the version history and evolution of the Android Studio logo and how it has changed over the years." + ), + Paragraph( + ParagraphType.Header, + "A brief history of the Android Studio logo" + ), + Paragraph( + ParagraphType.Bullet, + "2013: The original Android Studio logo was a 3D robot that highlighted the gears and interworking of the bugdroid. At this time, the Android Emulator was the bugdroid.", + listOf( + Markup(MarkupType.Bold, 0, 5) + ) + ), + Paragraph( + ParagraphType.Bullet, + "2014: The Android Emulator merged to a flat mark but remained otherwise unchanged.", + listOf( + Markup(MarkupType.Bold, 0, 5) + ) + ), + Paragraph( + ParagraphType.Bullet, + "2014-2019: An updated Android Studio logo was introduced featuring an \"A\" compass in front of a green circle.", + listOf( + Markup(MarkupType.Bold, 0, 10) + ) + ), + Paragraph( + ParagraphType.Bullet, + "2019: In Canary 3.6, the color palette was updated to match Android 10.", + listOf( + Markup(MarkupType.Bold, 0, 5) + ) + ), + Paragraph( + ParagraphType.Bullet, + "2020-2022: With the release of Android Studio 4.1 Canary, the \"A\" compass was reduced to an abstract form placed in front of a blueprint. The Android head was also added, peeking over the top.", + listOf( + Markup(MarkupType.Bold, 0, 10) + ) + ), + Paragraph( + ParagraphType.Header, + "Understanding the Android brand elements" + ), + Paragraph( + ParagraphType.Text, + "When redesigning a logo, it's important to consider brand elements that unify products within an ecosystem. For the Android Developer ecosystem, the \"robot head\" is a key brand element, alongside the primaryAndroid green color. The secondary colors blue and navy, and tertiary colors like orange, can also be utilized for support." + ), + Paragraph( + ParagraphType.Header, + "Key objectives" + ), + Paragraph( + ParagraphType.Bullet, + "Iconography: use recognizable and appropriate symbols, such as compass \"A\" for Android Studio or a device for Android Emulator, to convey the purpose and functionality clearly and quickly.", + listOf( + Markup(MarkupType.Bold, 0, 12) + ) + ), + Paragraph( + ParagraphType.Bullet, + "Enhance recognition and scalability: the Android Studio and Android Emulator should prioritize legibility and scalability, ensuring that they can be easily recognized and understood even at smaller sizes.", + listOf( + Markup(MarkupType.Bold, 0, 36) + ) + ), + Paragraph( + ParagraphType.Bullet, + "Establish distinction: the Android Studio and Android Emulator need to be easily distinguishable, to avoid confusion.", + listOf( + Markup(MarkupType.Bold, 0, 22) + ) + ), + Paragraph( + ParagraphType.Bullet, + "Maintain brand consistency: the Android Studio and Android Emulator designs should be consistent with the overall branding and visual identity of the Android family, while still being distinctive.", + listOf( + Markup(MarkupType.Bold, 0, 27) + ) + ), + Paragraph( + ParagraphType.Bullet, + "Ensure accessibility: the logo should be accessible to all users, including those with visual impairments. This means using clear shapes, colors, and contrast.", + listOf( + Markup(MarkupType.Bold, 0, 21) + ) + ), + Paragraph( + ParagraphType.Bullet, + "Follow OS guidelines: the updated application icon must align with the Android visual language and conform to the guidelines of macOS, Windows, and Linux operating systems, ensuring consistency and coherence across all platforms.", + listOf( + Markup(MarkupType.Bold, 0, 21) + ) + ), + Paragraph( + ParagraphType.Bullet, + "Ensure versatility: the Android Studio logo should be versatile enough to work in a variety of sizes and contexts, such as on different devices and platforms.", + listOf( + Markup(MarkupType.Bold, 0, 20) + ) + ), + Paragraph( + ParagraphType.Text, + "Read More", + listOf( + Markup( + MarkupType.Link, + 0, + 9, + "https://android-developers.googleblog.com/2023/05/redesigning-android-studio-logo.html" + ) + ) + ) +) + val post1 = Post( id = "dc523f0ed25c", title = "A Little Thing about Android Module Paths", @@ -1014,25 +1164,35 @@ val post5 = Post( imageThumbId = R.drawable.post_5_thumb ) -val posts: List<Post> = - listOf( - post1, - post2, - post3, - post4, - post5, - post1.copy(id = "post6"), - post2.copy(id = "post7"), - post3.copy(id = "post8"), - post4.copy(id = "post9"), - post5.copy(id = "post10") - ) +val post6 = Post( + id = "55db18283ac0", + title = "Redesigning the Android Studio Logo", + subtitle = "A case study offering a comprehensive overview of the design journey of the Android Studio product logo.", + url = "https://android-developers.googleblog.com/2023/05/redesigning-android-studio-logo.html", + publication = publication, + metadata = Metadata( + author = androidstudioteam, + date = "May 10", + readTimeMinutes = 5 + ), + paragraphs = paragraphsPost6, + imageId = R.drawable.post_6, + imageThumbId = R.drawable.post_6_thumb +) -fun getPostsWithImagesLoaded(posts: List<Post>, resources: Resources): List<Post> { - return posts.map { - it.copy( - image = imageFromResource(resources, it.imageId), - imageThumb = imageFromResource(resources, it.imageThumbId) +val posts: PostsFeed = + PostsFeed( + highlightedPost = post6, + recommendedPosts = listOf(post1, post2, post3), + popularPosts = listOf( + post5, + post1.copy(id = "post6"), + post2.copy(id = "post7") + ), + recentPosts = listOf( + post6, + post3.copy(id = "post8"), + post4.copy(id = "post9"), + post5.copy(id = "post10") ) - } -} + ) diff --git a/JetNews/app/src/main/java/com/example/jetnews/glance/JetnewsGlanceAppWidgetReceiver.kt b/JetNews/app/src/main/java/com/example/jetnews/glance/JetnewsGlanceAppWidgetReceiver.kt new file mode 100644 index 0000000000..a15caba190 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/glance/JetnewsGlanceAppWidgetReceiver.kt @@ -0,0 +1,25 @@ +/* + * 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.jetnews.glance + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import com.example.jetnews.glance.ui.JetnewsGlanceAppWidget + +class JetnewsGlanceAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = JetnewsGlanceAppWidget() +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/glance/ui/Divider.kt b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/Divider.kt new file mode 100644 index 0000000000..557f8a419a --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/Divider.kt @@ -0,0 +1,56 @@ +/* + * 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.jetnews.glance.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.background +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.unit.ColorProvider +import com.example.jetnews.glance.ui.theme.JetnewsGlanceColorScheme + +/** + * A thin line that groups content in lists and layouts. + * + * @param thickness thickness in dp of this divider line. + * @param color color of this divider line. + */ +@Composable +fun Divider( + thickness: Dp = DividerDefaults.Thickness, + color: ColorProvider = DividerDefaults.color +) { + Spacer( + modifier = GlanceModifier + .fillMaxWidth() + .height(thickness) + .background(color) + ) +} + +/** Default values for [Divider] */ +object DividerDefaults { + /** Default thickness of a divider. */ + val Thickness: Dp = 1.dp + + /** Default color of a divider. */ + val color: ColorProvider @Composable get() = JetnewsGlanceColorScheme.outlineVariant +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/glance/ui/JetnewsGlanceAppWidget.kt b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/JetnewsGlanceAppWidget.kt new file mode 100644 index 0000000000..063576e2e5 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/JetnewsGlanceAppWidget.kt @@ -0,0 +1,175 @@ +/* + * 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.jetnews.glance.ui + +import android.content.Context +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.itemsIndexed +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import com.example.jetnews.JetnewsApplication +import com.example.jetnews.R +import com.example.jetnews.data.successOr +import com.example.jetnews.glance.ui.theme.JetnewsGlanceColorScheme +import com.example.jetnews.model.Post +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class JetnewsGlanceAppWidget : GlanceAppWidget() { + override val sizeMode: SizeMode = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val application = context.applicationContext as JetnewsApplication + val postsRepository = application.container.postsRepository + + // Load data needed to render the composable. + // The widget is configured to refresh periodically using the "android:updatePeriodMillis" + // configuration, and during each refresh, the data is loaded here. + // The repository can internally return cached results here if it already has fresh data. + val initialPostsFeed = withContext(Dispatchers.IO) { + postsRepository.getPostsFeed().successOr(null) + } + val initialBookmarks: Set<String> = withContext(Dispatchers.IO) { + postsRepository.observeFavorites().first() + } + + provideContent { + val scope = rememberCoroutineScope() + val bookmarks by postsRepository.observeFavorites().collectAsState(initialBookmarks) + val postsFeed by postsRepository.observePostsFeed().collectAsState(initialPostsFeed) + val recommendedTopPosts = + postsFeed?.let { listOf(it.highlightedPost) + it.recommendedPosts } ?: emptyList() + + // Provide a custom color scheme if the SDK version doesn't support dynamic colors. + GlanceTheme( + colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + GlanceTheme.colors + } else { + JetnewsGlanceColorScheme.colors + } + ) { + JetnewsContent( + posts = recommendedTopPosts, + bookmarks = bookmarks, + onToggleBookmark = { scope.launch { postsRepository.toggleFavorite(it) } } + ) + } + } + } + + @Composable + private fun JetnewsContent( + posts: List<Post>, + bookmarks: Set<String>?, + onToggleBookmark: (String) -> Unit + ) { + Column( + modifier = GlanceModifier + .background(GlanceTheme.colors.surface) + .cornerRadius(24.dp) + ) { + Header(modifier = GlanceModifier.fillMaxWidth()) + // Set key for each size so that the onToggleBookmark lambda is called only once for the + // active size. + key(LocalSize.current) { + Body( + modifier = GlanceModifier.fillMaxWidth(), + posts = posts, + bookmarks = bookmarks ?: setOf(), + onToggleBookmark = onToggleBookmark + ) + } + } + } + + @Composable + fun Header(modifier: GlanceModifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.padding(horizontal = 10.dp, vertical = 20.dp) + ) { + val context = LocalContext.current + Image( + provider = ImageProvider(R.drawable.ic_jetnews_logo), + colorFilter = ColorFilter.tint(GlanceTheme.colors.primary), + contentDescription = null, + modifier = GlanceModifier.size(24.dp) + ) + Spacer(modifier = GlanceModifier.width(8.dp)) + Image( + contentDescription = context.getString(R.string.app_name), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), + provider = ImageProvider(R.drawable.ic_jetnews_wordmark) + ) + } + } + + @Composable + fun Body( + modifier: GlanceModifier, + posts: List<Post>, + bookmarks: Set<String>, + onToggleBookmark: (String) -> Unit, + ) { + val postLayout = LocalSize.current.toPostLayout() + LazyColumn(modifier = modifier.background(GlanceTheme.colors.background)) { + itemsIndexed(posts) { index, post -> + Column(modifier = GlanceModifier.padding(horizontal = 14.dp)) { + Post( + post = post, + bookmarks = bookmarks, + onToggleBookmark = onToggleBookmark, + modifier = GlanceModifier.fillMaxWidth().padding(15.dp), + postLayout = postLayout, + ) + if (index < posts.lastIndex) { + Divider() + } + } + } + } + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/glance/ui/Post.kt b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/Post.kt new file mode 100644 index 0000000000..3c8fea4f15 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/Post.kt @@ -0,0 +1,232 @@ +/* + * 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.jetnews.glance.ui + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.Text +import com.example.jetnews.JetnewsApplication.Companion.JETNEWS_APP_URI +import com.example.jetnews.R +import com.example.jetnews.glance.ui.theme.JetnewsGlanceTextStyles +import com.example.jetnews.model.Post +import com.example.jetnews.ui.MainActivity + +enum class PostLayout { HORIZONTAL_SMALL, HORIZONTAL_LARGE, VERTICAL } + +fun DpSize.toPostLayout(): PostLayout { + return when { + (this.width <= 300.dp) -> PostLayout.VERTICAL + (this.width <= 700.dp) -> PostLayout.HORIZONTAL_SMALL + else -> PostLayout.HORIZONTAL_LARGE + } +} + +private fun Context.authorReadTimeString(author: String, readTimeMinutes: Int) = + getString(R.string.home_post_min_read) + .format(author, readTimeMinutes) + +private fun openPostDetails(context: Context, post: Post): Action { + // actionStartActivity is the preferred way to start activities. + return actionStartActivity( + Intent( + Intent.ACTION_VIEW, + "$JETNEWS_APP_URI/home?postId=${post.id}".toUri(), + context, + MainActivity::class.java + ) + ) +} + +@Composable +fun Post( + post: Post, + bookmarks: Set<String>, + onToggleBookmark: (String) -> Unit, + modifier: GlanceModifier, + postLayout: PostLayout, +) { + when (postLayout) { + PostLayout.HORIZONTAL_SMALL -> HorizontalPost( + post = post, + bookmarks = bookmarks, + onToggleBookmark = onToggleBookmark, + modifier = modifier, + ) + + PostLayout.HORIZONTAL_LARGE -> HorizontalPost( + post = post, + bookmarks = bookmarks, + onToggleBookmark = onToggleBookmark, + modifier = modifier, + showImageThumbnail = false + ) + + PostLayout.VERTICAL -> VerticalPost( + post = post, + bookmarks = bookmarks, + onToggleBookmark = onToggleBookmark, + modifier = modifier, + ) + } +} + +@Composable +fun HorizontalPost( + post: Post, + bookmarks: Set<String>, + onToggleBookmark: (String) -> Unit, + modifier: GlanceModifier, + showImageThumbnail: Boolean = true +) { + val context = LocalContext.current + Row( + verticalAlignment = Alignment.Vertical.CenterVertically, + modifier = modifier.clickable(onClick = openPostDetails(context, post)) + ) { + if (showImageThumbnail) { + PostImage( + imageId = post.imageThumbId, + contentScale = ContentScale.Fit, + modifier = GlanceModifier.size(80.dp) + ) + } else { + PostImage( + imageId = post.imageId, + contentScale = ContentScale.Crop, + modifier = GlanceModifier.width(250.dp) + ) + } + PostDescription( + title = post.title, + metadata = context.authorReadTimeString( + author = post.metadata.author.name, + readTimeMinutes = post.metadata.readTimeMinutes + ), + modifier = GlanceModifier.defaultWeight().padding(horizontal = 20.dp) + ) + BookmarkButton( + id = post.id, + isBookmarked = bookmarks.contains(post.id), + onToggleBookmark = onToggleBookmark + ) + } +} + +@Composable +fun VerticalPost( + post: Post, + bookmarks: Set<String>, + onToggleBookmark: (String) -> Unit, + modifier: GlanceModifier, +) { + val context = LocalContext.current + Column( + verticalAlignment = Alignment.Vertical.CenterVertically, + modifier = modifier.clickable(onClick = openPostDetails(context, post)) + ) { + PostImage(imageId = post.imageId, modifier = GlanceModifier.fillMaxWidth()) + Spacer(modifier = GlanceModifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + PostDescription( + title = post.title, + metadata = context.authorReadTimeString( + author = post.metadata.author.name, + readTimeMinutes = post.metadata.readTimeMinutes + ), + modifier = GlanceModifier.defaultWeight() + ) + Spacer(modifier = GlanceModifier.width(10.dp)) + BookmarkButton( + id = post.id, + isBookmarked = bookmarks.contains(post.id), + onToggleBookmark = onToggleBookmark + ) + } + } +} + +@Composable +fun BookmarkButton(id: String, isBookmarked: Boolean, onToggleBookmark: (String) -> Unit) { + Image( + provider = ImageProvider( + if (isBookmarked) { + R.drawable.ic_jetnews_bookmark_filled + } else { + R.drawable.ic_jetnews_bookmark + } + ), + colorFilter = ColorFilter.tint(GlanceTheme.colors.primary), + contentDescription = "${if (isBookmarked) R.string.unbookmark else R.string.bookmark}", + modifier = GlanceModifier.clickable { onToggleBookmark(id) } + ) +} + +@Composable +fun PostImage( + imageId: Int, + contentScale: ContentScale = ContentScale.Crop, + modifier: GlanceModifier = GlanceModifier +) { + Image( + provider = ImageProvider(imageId), + contentScale = contentScale, + contentDescription = null, + modifier = modifier.cornerRadius(5.dp) + ) +} + +@Composable +fun PostDescription(title: String, metadata: String, modifier: GlanceModifier) { + Column(modifier = modifier) { + Text( + text = title, + maxLines = 3, + style = JetnewsGlanceTextStyles.bodyLarge + .copy(color = GlanceTheme.colors.onBackground) + ) + Spacer(modifier = GlanceModifier.height(4.dp)) + Text( + text = metadata, + style = JetnewsGlanceTextStyles.bodySmall + .copy(color = GlanceTheme.colors.onBackground) + ) + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/glance/ui/theme/Theme.kt b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/theme/Theme.kt new file mode 100644 index 0000000000..80ca05b38e --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/theme/Theme.kt @@ -0,0 +1,34 @@ +/* + * 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.jetnews.glance.ui.theme + +import androidx.glance.color.ColorProvider +import androidx.glance.material3.ColorProviders +import com.example.jetnews.ui.theme.DarkColors +import com.example.jetnews.ui.theme.LightColors + +object JetnewsGlanceColorScheme { + val colors = ColorProviders( + light = LightColors, + dark = DarkColors + ) + + val outlineVariant = ColorProvider( + day = LightColors.onSurface.copy(alpha = 0.1f), + night = DarkColors.onSurface.copy(alpha = 0.1f) + ) +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/glance/ui/theme/Type.kt b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/theme/Type.kt new file mode 100644 index 0000000000..61f682a31f --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/glance/ui/theme/Type.kt @@ -0,0 +1,25 @@ +/* + * 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.jetnews.glance.ui.theme + +import androidx.compose.ui.unit.sp +import androidx.glance.text.TextStyle + +object JetnewsGlanceTextStyles { + val bodyLarge = TextStyle(fontSize = 16.sp) + val bodySmall = TextStyle(fontSize = 12.sp) +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/model/Post.kt b/JetNews/app/src/main/java/com/example/jetnews/model/Post.kt index df48593e72..9bd77c1364 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/model/Post.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/model/Post.kt @@ -16,7 +16,7 @@ package com.example.jetnews.model -import androidx.compose.ui.graphics.ImageBitmap +import androidx.annotation.DrawableRes data class Post( val id: String, @@ -26,10 +26,8 @@ data class Post( val publication: Publication? = null, val metadata: Metadata, val paragraphs: List<Paragraph> = emptyList(), - val imageId: Int, - val imageThumbId: Int, - val image: ImageBitmap? = null, - val imageThumb: ImageBitmap? = null + @DrawableRes val imageId: Int, + @DrawableRes val imageThumbId: Int ) data class Metadata( diff --git a/JetNews/app/src/main/java/com/example/jetnews/model/PostsFeed.kt b/JetNews/app/src/main/java/com/example/jetnews/model/PostsFeed.kt new file mode 100644 index 0000000000..95cd196fad --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/model/PostsFeed.kt @@ -0,0 +1,33 @@ +/* + * 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.jetnews.model + +/** + * A container of [Post]s, partitioned into different categories. + */ +data class PostsFeed( + val highlightedPost: Post, + val recommendedPosts: List<Post>, + val popularPosts: List<Post>, + val recentPosts: List<Post>, +) { + /** + * Returns a flattened list of all posts contained in the feed. + */ + val allPosts: List<Post> = + listOf(highlightedPost) + recommendedPosts + popularPosts + recentPosts +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/AppDrawer.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/AppDrawer.kt index 778095d2a6..29a9401ced 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/AppDrawer.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/AppDrawer.kt @@ -16,63 +16,61 @@ package com.example.jetnews.ui -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import android.content.res.Configuration.UI_MODE_NIGHT_YES 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.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.ListAlt +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource +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.jetnews.R +import com.example.jetnews.ui.theme.JetnewsTheme @Composable fun AppDrawer( - navigateTo: (Screen) -> Unit, - currentScreen: Screen, - closeDrawer: () -> Unit + drawerState: DrawerState, + currentRoute: String, + navigateToHome: () -> Unit, + navigateToInterests: () -> Unit, + closeDrawer: () -> Unit, + modifier: Modifier = Modifier ) { - Column(modifier = Modifier.fillMaxSize()) { - Spacer(Modifier.preferredHeight(24.dp)) - JetNewsLogo(Modifier.padding(16.dp)) - Divider(color = MaterialTheme.colors.onSurface.copy(alpha = .2f)) - DrawerButton( - icon = Icons.Filled.Home, - label = "Home", - isSelected = currentScreen == Screen.Home, - action = { - navigateTo(Screen.Home) - closeDrawer() - } + ModalDrawerSheet( + drawerState = drawerState, + modifier = modifier, + ) { + JetNewsLogo( + modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp) ) - - DrawerButton( - icon = Icons.Filled.ListAlt, - label = "Interests", - isSelected = currentScreen == Screen.Interests, - action = { - navigateTo(Screen.Interests) - closeDrawer() - } + NavigationDrawerItem( + label = { Text(stringResource(id = R.string.home_title)) }, + icon = { Icon(Icons.Filled.Home, null) }, + selected = currentRoute == JetnewsDestinations.HOME_ROUTE, + onClick = { navigateToHome(); closeDrawer() }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) + ) + NavigationDrawerItem( + label = { Text(stringResource(id = R.string.interests_title)) }, + icon = { Icon(Icons.Filled.ListAlt, null) }, + selected = currentRoute == JetnewsDestinations.INTERESTS_ROUTE, + onClick = { navigateToInterests(); closeDrawer() }, + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) ) } } @@ -80,95 +78,30 @@ fun AppDrawer( @Composable private fun JetNewsLogo(modifier: Modifier = Modifier) { Row(modifier = modifier) { - Image( - imageVector = vectorResource(R.drawable.ic_jetnews_logo), - colorFilter = ColorFilter.tint(MaterialTheme.colors.primary) + Icon( + painterResource(R.drawable.ic_jetnews_logo), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) - Spacer(Modifier.preferredWidth(8.dp)) - Image( - imageVector = vectorResource(R.drawable.ic_jetnews_wordmark), - colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface) + Spacer(Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_jetnews_wordmark), + contentDescription = stringResource(R.string.app_name), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } -@Composable -private fun DrawerButton( - icon: ImageVector, - label: String, - isSelected: Boolean, - action: () -> Unit, - modifier: Modifier = Modifier -) { - val colors = MaterialTheme.colors - val imageAlpha = if (isSelected) { - 1f - } else { - 0.6f - } - val textIconColor = if (isSelected) { - colors.primary - } else { - colors.onSurface.copy(alpha = 0.6f) - } - val backgroundColor = if (isSelected) { - colors.primary.copy(alpha = 0.12f) - } else { - Color.Transparent - } - - val surfaceModifier = modifier - .padding(start = 8.dp, top = 8.dp, end = 8.dp) - .fillMaxWidth() - Surface( - modifier = surfaceModifier, - color = backgroundColor, - shape = MaterialTheme.shapes.small - ) { - TextButton( - onClick = action, - modifier = Modifier.fillMaxWidth() - ) { - Row( - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Image( - imageVector = icon, - colorFilter = ColorFilter.tint(textIconColor), - alpha = imageAlpha - ) - Spacer(Modifier.preferredWidth(16.dp)) - Text( - text = label, - style = MaterialTheme.typography.body2, - color = textIconColor - ) - } - } - } -} - @Preview("Drawer contents") +@Preview("Drawer contents (dark)", uiMode = UI_MODE_NIGHT_YES) @Composable fun PreviewAppDrawer() { - ThemedPreview { - AppDrawer( - navigateTo = { }, - currentScreen = Screen.Home, - closeDrawer = { } - ) - } -} - -@Preview("Drawer contents dark theme") -@Composable -fun PreviewAppDrawerDark() { - ThemedPreview(darkTheme = true) { + JetnewsTheme { AppDrawer( - navigateTo = { }, - currentScreen = Screen.Home, + drawerState = rememberDrawerState(initialValue = DrawerValue.Open), + currentRoute = JetnewsDestinations.HOME_ROUTE, + navigateToHome = {}, + navigateToInterests = {}, closeDrawer = { } ) } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt index 4b9d26267e..1d65a206df 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt @@ -16,55 +16,91 @@ package com.example.jetnews.ui -import androidx.compose.animation.Crossfade -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController import com.example.jetnews.data.AppContainer -import com.example.jetnews.data.interests.InterestsRepository -import com.example.jetnews.data.posts.PostsRepository -import com.example.jetnews.ui.article.ArticleScreen -import com.example.jetnews.ui.home.HomeScreen -import com.example.jetnews.ui.interests.InterestsScreen +import com.example.jetnews.ui.components.AppNavRail import com.example.jetnews.ui.theme.JetnewsTheme +import kotlinx.coroutines.launch @Composable fun JetnewsApp( appContainer: AppContainer, - navigationViewModel: NavigationViewModel + widthSizeClass: WindowWidthSizeClass, ) { JetnewsTheme { - AppContent( - navigationViewModel = navigationViewModel, - interestsRepository = appContainer.interestsRepository, - postsRepository = appContainer.postsRepository - ) - } -} + val navController = rememberNavController() + val navigationActions = remember(navController) { + JetnewsNavigationActions(navController) + } -@Composable -private fun AppContent( - navigationViewModel: NavigationViewModel, - postsRepository: PostsRepository, - interestsRepository: InterestsRepository -) { - Crossfade(navigationViewModel.currentScreen) { screen -> - Surface(color = MaterialTheme.colors.background) { - when (screen) { - is Screen.Home -> HomeScreen( - navigateTo = navigationViewModel::navigateTo, - postsRepository = postsRepository - ) - is Screen.Interests -> InterestsScreen( - navigateTo = navigationViewModel::navigateTo, - interestsRepository = interestsRepository + val coroutineScope = rememberCoroutineScope() + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = + navBackStackEntry?.destination?.route ?: JetnewsDestinations.HOME_ROUTE + + val isExpandedScreen = widthSizeClass == WindowWidthSizeClass.Expanded + val sizeAwareDrawerState = rememberSizeAwareDrawerState(isExpandedScreen) + + ModalNavigationDrawer( + drawerContent = { + AppDrawer( + drawerState = sizeAwareDrawerState, + currentRoute = currentRoute, + navigateToHome = navigationActions.navigateToHome, + navigateToInterests = navigationActions.navigateToInterests, + closeDrawer = { coroutineScope.launch { sizeAwareDrawerState.close() } } ) - is Screen.Article -> ArticleScreen( - postId = screen.postId, - postsRepository = postsRepository, - onBack = { navigationViewModel.onBack() } + }, + drawerState = sizeAwareDrawerState, + // Only enable opening the drawer via gestures if the screen is not expanded + gesturesEnabled = !isExpandedScreen + ) { + Row { + if (isExpandedScreen) { + AppNavRail( + currentRoute = currentRoute, + navigateToHome = navigationActions.navigateToHome, + navigateToInterests = navigationActions.navigateToInterests, + ) + } + JetnewsNavGraph( + appContainer = appContainer, + isExpandedScreen = isExpandedScreen, + navController = navController, + openDrawer = { coroutineScope.launch { sizeAwareDrawerState.open() } }, ) } } } } + +/** + * Determine the drawer state to pass to the modal drawer. + */ +@Composable +private fun rememberSizeAwareDrawerState(isExpandedScreen: Boolean): DrawerState { + val drawerState = rememberDrawerState(DrawerValue.Closed) + + return if (!isExpandedScreen) { + // If we want to allow showing the drawer, we use a real, remembered drawer + // state defined above + drawerState + } else { + // If we don't want to allow the drawer to be shown, we provide a drawer state + // that is locked closed. This is intentionally not remembered, because we + // don't want to keep track of any changes and always keep it closed + DrawerState(DrawerValue.Closed) + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt new file mode 100644 index 0000000000..67fccd2dd1 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt @@ -0,0 +1,82 @@ +/* + * 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.jetnews.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navDeepLink +import com.example.jetnews.JetnewsApplication.Companion.JETNEWS_APP_URI +import com.example.jetnews.data.AppContainer +import com.example.jetnews.ui.home.HomeRoute +import com.example.jetnews.ui.home.HomeViewModel +import com.example.jetnews.ui.interests.InterestsRoute +import com.example.jetnews.ui.interests.InterestsViewModel + +const val POST_ID = "postId" + +@Composable +fun JetnewsNavGraph( + appContainer: AppContainer, + isExpandedScreen: Boolean, + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController(), + openDrawer: () -> Unit = {}, + startDestination: String = JetnewsDestinations.HOME_ROUTE, +) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier + ) { + composable( + route = JetnewsDestinations.HOME_ROUTE, + deepLinks = listOf( + navDeepLink { + uriPattern = + "$JETNEWS_APP_URI/${JetnewsDestinations.HOME_ROUTE}?$POST_ID={$POST_ID}" + } + ) + ) { navBackStackEntry -> + val homeViewModel: HomeViewModel = viewModel( + factory = HomeViewModel.provideFactory( + postsRepository = appContainer.postsRepository, + preSelectedPostId = navBackStackEntry.arguments?.getString(POST_ID) + ) + ) + HomeRoute( + homeViewModel = homeViewModel, + isExpandedScreen = isExpandedScreen, + openDrawer = openDrawer, + ) + } + composable(JetnewsDestinations.INTERESTS_ROUTE) { + val interestsViewModel: InterestsViewModel = viewModel( + factory = InterestsViewModel.provideFactory(appContainer.interestsRepository) + ) + InterestsRoute( + interestsViewModel = interestsViewModel, + isExpandedScreen = isExpandedScreen, + openDrawer = openDrawer + ) + } + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavigation.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavigation.kt new file mode 100644 index 0000000000..8dd1ee01d6 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavigation.kt @@ -0,0 +1,58 @@ +/* + * 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.jetnews.ui + +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController + +/** + * Destinations used in the [JetnewsApp]. + */ +object JetnewsDestinations { + const val HOME_ROUTE = "home" + const val INTERESTS_ROUTE = "interests" +} + +/** + * Models the navigation actions in the app. + */ +class JetnewsNavigationActions(navController: NavHostController) { + val navigateToHome: () -> Unit = { + navController.navigate(JetnewsDestinations.HOME_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 navigateToInterests: () -> Unit = { + navController.navigate(JetnewsDestinations.INTERESTS_ROUTE) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt index 36efa9f181..bd6d2ac0bd 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt @@ -17,27 +17,24 @@ package com.example.jetnews.ui import android.os.Bundle -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.ui.platform.setContent +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.jetnews.JetnewsApplication -class MainActivity : AppCompatActivity() { - - private val navigationViewModel by viewModels<NavigationViewModel>() +class MainActivity : ComponentActivity() { + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) val appContainer = (application as JetnewsApplication).container setContent { - JetnewsApp(appContainer, navigationViewModel) - } - } - - override fun onBackPressed() { - if (!navigationViewModel.onBack()) { - super.onBackPressed() + val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass + JetnewsApp(appContainer, widthSizeClass) } } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/Navigation.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/Navigation.kt deleted file mode 100644 index e11677dc40..0000000000 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/Navigation.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetnews.ui - -import android.os.Bundle -import androidx.annotation.MainThread -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.core.os.bundleOf -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import com.example.jetnews.ui.Screen.Article -import com.example.jetnews.ui.Screen.Home -import com.example.jetnews.ui.Screen.Interests -import com.example.jetnews.ui.ScreenName.ARTICLE -import com.example.jetnews.ui.ScreenName.HOME -import com.example.jetnews.ui.ScreenName.INTERESTS -import com.example.jetnews.utils.getMutableStateOf - -/** - * Screen names (used for serialization) - */ -enum class ScreenName { HOME, INTERESTS, ARTICLE } - -/** - * Class defining the screens we have in the app: home, article details and interests - */ -sealed class Screen(val id: ScreenName) { - object Home : Screen(HOME) - object Interests : Screen(INTERESTS) - data class Article(val postId: String) : Screen(ARTICLE) -} - -/** - * Helpers for saving and loading a [Screen] object to a [Bundle]. - * - * This allows us to persist navigation across process death, for example caused by a long video - * call. - */ -private const val SIS_SCREEN = "sis_screen" -private const val SIS_NAME = "screen_name" -private const val SIS_POST = "post" - -/** - * Convert a screen to a bundle that can be stored in [SavedStateHandle] - */ -private fun Screen.toBundle(): Bundle { - return bundleOf(SIS_NAME to id.name).also { - // add extra keys for various types here - if (this is Article) { - it.putString(SIS_POST, postId) - } - } -} - -/** - * Read a bundle stored by [Screen.toBundle] and return desired screen. - * - * @return the parsed [Screen] - * @throws IllegalArgumentException if the bundle could not be parsed - */ -private fun Bundle.toScreen(): Screen { - val screenName = ScreenName.valueOf(getStringOrThrow(SIS_NAME)) - return when (screenName) { - HOME -> Home - INTERESTS -> Interests - ARTICLE -> { - val postId = getStringOrThrow(SIS_POST) - Article(postId) - } - } -} - -/** - * Throw [IllegalArgumentException] if key is not in bundle. - * - * @see Bundle.getString - */ -private fun Bundle.getStringOrThrow(key: String) = - requireNotNull(getString(key)) { "Missing key '$key' in $this" } - -/** - * This is expected to be replaced by the navigation component, but for now handle navigation - * manually. - * - * Instantiate this ViewModel at the scope that is fully-responsible for navigation, which in this - * application is [MainActivity]. - * - * This app has simplified navigation; the back stack is always [Home] or [Home, dest] and more - * levels are not allowed. To use a similar pattern with a longer back stack, use a [StateList] to - * hold the back stack state. - */ -class NavigationViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { - /** - * Hold the current screen in an observable, restored from savedStateHandle after process - * death. - * - * mutableStateOf is an observable similar to LiveData that's designed to be read by compose. It - * supports observability via property delegate syntax as shown here. - */ - var currentScreen: Screen by savedStateHandle.getMutableStateOf<Screen>( - key = SIS_SCREEN, - default = Home, - save = { it.toBundle() }, - restore = { it.toScreen() } - ) - private set // limit the writes to only inside this class. - - /** - * Go back (always to [Home]). - * - * Returns true if this call caused user-visible navigation. Will always return false - * when [currentScreen] is [Home]. - */ - @MainThread - fun onBack(): Boolean { - val wasHandled = currentScreen != Home - currentScreen = Home - return wasHandled - } - - /** - * Navigate to requested [Screen]. - * - * If the requested screen is not [Home], it will always create a back stack with one element: - * ([Home] -> [screen]). More back entries are not supported in this app. - */ - @MainThread - fun navigateTo(screen: Screen) { - currentScreen = screen - } -} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/PreviewUtils.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/PreviewUtils.kt deleted file mode 100644 index b7b466fda7..0000000000 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/PreviewUtils.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetnews.ui - -import androidx.compose.material.Surface -import androidx.compose.runtime.Composable -import com.example.jetnews.ui.theme.JetnewsTheme - -@Composable -internal fun ThemedPreview( - darkTheme: Boolean = false, - content: @Composable () -> Unit -) { - JetnewsTheme(darkTheme = darkTheme) { - Surface { - content() - } - } -} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/SwipeToRefresh.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/SwipeToRefresh.kt deleted file mode 100644 index 5e04ec06fa..0000000000 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/SwipeToRefresh.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetnews.ui - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FractionalThreshold -import androidx.compose.material.SwipeableState -import androidx.compose.material.rememberSwipeableState -import androidx.compose.material.swipeable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.onCommit -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.gesture.nestedscroll.NestedScrollConnection -import androidx.compose.ui.gesture.nestedscroll.NestedScrollSource -import androidx.compose.ui.gesture.nestedscroll.nestedScroll -import androidx.compose.ui.gesture.scrollorientationlocking.Orientation -import androidx.compose.ui.platform.AmbientDensity -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.unit.dp -import kotlin.math.roundToInt - -private val RefreshDistance = 80.dp - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun SwipeToRefreshLayout( - refreshingState: Boolean, - onRefresh: () -> Unit, - refreshIndicator: @Composable () -> Unit, - content: @Composable () -> Unit -) { - val refreshDistance = with(AmbientDensity.current) { RefreshDistance.toPx() } - val state = rememberSwipeableState(refreshingState) { newValue -> - // compare both copies of the swipe state before calling onRefresh(). This is a workaround. - if (newValue && !refreshingState) onRefresh() - true - } - - Box( - modifier = Modifier - .nestedScroll(state.PreUpPostDownNestedScrollConnection) - .swipeable( - state = state, - anchors = mapOf( - -refreshDistance to false, - refreshDistance to true - ), - thresholds = { _, _ -> FractionalThreshold(0.5f) }, - orientation = Orientation.Vertical - ) - ) { - content() - Box( - Modifier - .align(Alignment.TopCenter) - .offset { IntOffset(0, state.offset.value.roundToInt()) } - ) { - if (state.offset.value != -refreshDistance) { - refreshIndicator() - } - } - - // TODO (https://issuetracker.google.com/issues/164113834): This state->event trampoline is a - // workaround for a bug in the SwipableState API. Currently, state.value is a duplicated - // source of truth of refreshingState. - onCommit(refreshingState) { - state.animateTo(refreshingState) - } - } -} - -/** - * Temporary workaround for nested scrolling behavior. There is no default implementation for - * pull to refresh yet, this nested scroll connection mimics the behavior. - */ -@ExperimentalMaterialApi -private val <T> SwipeableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection - get() = object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val delta = available.toFloat() - return if (delta < 0 && source == NestedScrollSource.Drag) { - performDrag(delta).toOffset() - } else { - Offset.Zero - } - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - return if (source == NestedScrollSource.Drag) { - performDrag(available.toFloat()).toOffset() - } else { - Offset.Zero - } - } - - override fun onPreFling(available: Velocity): Velocity { - val toFling = Offset(available.x, available.y).toFloat() - return if (toFling < 0) { - performFling(velocity = toFling) {} - // since we go to the anchor with tween settling, consume all for the best UX - available - } else { - Velocity.Zero - } - } - - override fun onPostFling( - consumed: Velocity, - available: Velocity, - onFinished: (Velocity) -> Unit - ) { - performFling(velocity = Offset(available.x, available.y).toFloat()) { - // since we go to the anchor with tween settling, consume all for the best UX - onFinished.invoke(available) - } - } - - private fun Float.toOffset(): Offset = Offset(0f, this) - - private fun Offset.toFloat(): Float = this.y - } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt index 996705b543..5891166cbb 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt @@ -18,183 +18,184 @@ package com.example.jetnews.ui.article import android.content.Context import android.content.Intent +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.material.AlertDialog -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.TopAppBar +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.FavoriteBorder -import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.savedinstancestate.savedInstanceState +import androidx.compose.runtime.mutableStateOf +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.platform.AmbientContext -import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.jetnews.R import com.example.jetnews.data.Result -import com.example.jetnews.data.posts.PostsRepository import com.example.jetnews.data.posts.impl.BlockingFakePostsRepository import com.example.jetnews.data.posts.impl.post3 import com.example.jetnews.model.Post -import com.example.jetnews.ui.ThemedPreview -import com.example.jetnews.ui.home.BookmarkButton -import com.example.jetnews.utils.produceUiState -import kotlinx.coroutines.launch +import com.example.jetnews.ui.theme.JetnewsTheme +import com.example.jetnews.ui.utils.BookmarkButton +import com.example.jetnews.ui.utils.FavoriteButton +import com.example.jetnews.ui.utils.ShareButton +import com.example.jetnews.ui.utils.TextSettingsButton import kotlinx.coroutines.runBlocking /** - * Stateful Article Screen that manages state using [produceUiState] - * - * @param postId (state) the post to show - * @param postsRepository data source for this screen - * @param onBack (event) request back navigation - */ -@Suppress("DEPRECATION") // allow ViewModelLifecycleScope call -@Composable -fun ArticleScreen( - postId: String, - postsRepository: PostsRepository, - onBack: () -> Unit -) { - val (post) = produceUiState(postsRepository, postId) { - getPost(postId) - } - // TODO: handle errors when the repository is capable of creating them - val postData = post.value.data ?: return - - // [collectAsState] will automatically collect a Flow<T> and return a State<T> object that - // updates whenever the Flow emits a value. Collection is cancelled when [collectAsState] is - // removed from the composition tree. - val favorites by postsRepository.observeFavorites().collectAsState(setOf()) - val isFavorite = favorites.contains(postId) - - // Returns a [CoroutineScope] that is scoped to the lifecycle of [ArticleScreen]. When this - // screen is removed from composition, the scope will be cancelled. - val coroutineScope = rememberCoroutineScope() - - ArticleScreen( - post = postData, - onBack = onBack, - isFavorite = isFavorite, - onToggleFavorite = { - coroutineScope.launch { postsRepository.toggleFavorite(postId) } - } - ) -} - -/** - * Stateless Article Screen that displays a single post. + * Stateless Article Screen that displays a single post adapting the UI to different screen sizes. * * @param post (state) item to display + * @param showNavigationIcon (state) if the navigation icon should be shown * @param onBack (event) request navigate back * @param isFavorite (state) is this item currently a favorite * @param onToggleFavorite (event) request that this post toggle it's favorite state + * @param lazyListState (state) the [LazyListState] for the article content */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ArticleScreen( post: Post, + isExpandedScreen: Boolean, onBack: () -> Unit, isFavorite: Boolean, - onToggleFavorite: () -> Unit + onToggleFavorite: () -> Unit, + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState() ) { - - var showDialog by savedInstanceState { false } - if (showDialog) { - FunctionalityNotAvailablePopup { showDialog = false } + var showUnimplementedActionDialog by rememberSaveable { mutableStateOf(false) } + if (showUnimplementedActionDialog) { + FunctionalityNotAvailablePopup { showUnimplementedActionDialog = false } } - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = "Published in: ${post.publication?.name}", - style = MaterialTheme.typography.subtitle2, - color = AmbientContentColor.current - ) - }, - navigationIcon = { + Row(modifier.fillMaxSize()) { + val context = LocalContext.current + ArticleScreenContent( + post = post, + // Allow opening the Drawer if the screen is not expanded + navigationIconContent = { + if (!isExpandedScreen) { IconButton(onClick = onBack) { - Icon(Icons.Filled.ArrowBack) + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_navigate_up), + tint = MaterialTheme.colorScheme.primary + ) } } - ) - }, - bodyContent = { innerPadding -> - val modifier = Modifier.padding(innerPadding) - PostContent(post, modifier) - }, - bottomBar = { - BottomBar( - post = post, - onUnimplementedAction = { showDialog = true }, - isFavorite = isFavorite, - onToggleFavorite = onToggleFavorite - ) - } - ) + }, + // Show the bottom bar if the screen is not expanded + bottomBarContent = { + if (!isExpandedScreen) { + BottomAppBar( + actions = { + FavoriteButton(onClick = { showUnimplementedActionDialog = true }) + BookmarkButton(isBookmarked = isFavorite, onClick = onToggleFavorite) + ShareButton(onClick = { sharePost(post, context) }) + TextSettingsButton(onClick = { showUnimplementedActionDialog = true }) + } + ) + } + }, + lazyListState = lazyListState + ) + } } /** - * Bottom bar for Article screen + * Stateless Article Screen that displays a single post. * - * @param post (state) used in share sheet to share the post - * @param onUnimplementedAction (event) called when the user performs an unimplemented action - * @param isFavorite (state) if this post is currently a favorite - * @param onToggleFavorite (event) request this post toggle it's favorite status + * @param post (state) item to display + * @param navigationIconContent (UI) content to show for the navigation icon + * @param bottomBarContent (UI) content to show for the bottom bar */ +@ExperimentalMaterial3Api @Composable -private fun BottomBar( +private fun ArticleScreenContent( post: Post, - onUnimplementedAction: () -> Unit, - isFavorite: Boolean, - onToggleFavorite: () -> Unit + navigationIconContent: @Composable () -> Unit = { }, + bottomBarContent: @Composable () -> Unit = { }, + lazyListState: LazyListState = rememberLazyListState() ) { - Surface(elevation = 2.dp) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .preferredHeight(56.dp) - .fillMaxWidth() - ) { - IconButton(onClick = onUnimplementedAction) { - Icon(Icons.Filled.FavoriteBorder) - } - BookmarkButton( - isBookmarked = isFavorite, - onClick = onToggleFavorite + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState) + Scaffold( + topBar = { + TopAppBar( + title = post.publication?.name.orEmpty(), + navigationIconContent = navigationIconContent, + scrollBehavior = scrollBehavior ) - val context = AmbientContext.current - IconButton(onClick = { sharePost(post, context) }) { - Icon(Icons.Filled.Share) - } - Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = onUnimplementedAction) { - Icon(vectorResource(R.drawable.ic_text_settings)) - } - } + }, + bottomBar = bottomBarContent + ) { innerPadding -> + PostContent( + post = post, + contentPadding = innerPadding, + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + state = lazyListState, + ) } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBar( + title: String, + navigationIconContent: @Composable () -> Unit, + scrollBehavior: TopAppBarScrollBehavior?, + modifier: Modifier = Modifier +) { + CenterAlignedTopAppBar( + title = { + Row { + Image( + painter = painterResource(id = R.drawable.icon_article_background), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(36.dp) + ) + Text( + text = stringResource(R.string.published_in, title), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } + }, + navigationIcon = navigationIconContent, + scrollBehavior = scrollBehavior, + modifier = modifier + ) +} + /** * Display a popup explaining functionality not available. * @@ -206,13 +207,13 @@ private fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) { onDismissRequest = onDismiss, text = { Text( - text = "Functionality not available \uD83D\uDE48", - style = MaterialTheme.typography.body2 + text = stringResource(id = R.string.article_functionality_not_available), + style = MaterialTheme.typography.bodyLarge ) }, confirmButton = { TextButton(onClick = onDismiss) { - Text(text = "CLOSE") + Text(text = stringResource(id = R.string.close)) } } ) @@ -224,38 +225,46 @@ private fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) { * @param post to share * @param context Android context to show the share sheet in */ -private fun sharePost(post: Post, context: Context) { +fun sharePost(post: Post, context: Context) { val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TITLE, post.title) putExtra(Intent.EXTRA_TEXT, post.url) } - context.startActivity(Intent.createChooser(intent, "Share post")) + context.startActivity( + Intent.createChooser( + intent, + context.getString(R.string.article_share_post) + ) + ) } @Preview("Article screen") +@Preview("Article screen (dark)", uiMode = UI_MODE_NIGHT_YES) +@Preview("Article screen (big font)", fontScale = 1.5f) @Composable -fun PreviewArticle() { - ThemedPreview { - val post = loadFakePost(post3.id) - ArticleScreen(post, {}, false, {}) - } -} - -@Preview("Article screen dark theme") -@Composable -fun PreviewArticleDark() { - ThemedPreview(darkTheme = true) { - val post = loadFakePost(post3.id) - ArticleScreen(post, {}, false, {}) +fun PreviewArticleDrawer() { + JetnewsTheme { + val post = runBlocking { + (BlockingFakePostsRepository().getPost(post3.id) as Result.Success).data + } + ArticleScreen(post, false, {}, false, {}) } } +@Preview("Article screen navrail", device = Devices.PIXEL_C) +@Preview( + "Article screen navrail (dark)", + uiMode = UI_MODE_NIGHT_YES, + device = Devices.PIXEL_C +) +@Preview("Article screen navrail (big font)", fontScale = 1.5f, device = Devices.PIXEL_C) @Composable -private fun loadFakePost(postId: String): Post { - val context = AmbientContext.current - val post = runBlocking { - (BlockingFakePostsRepository(context).getPost(postId) as Result.Success).data +fun PreviewArticleNavRail() { + JetnewsTheme { + val post = runBlocking { + (BlockingFakePostsRepository().getPost(post3.id) as Result.Success).data + } + ArticleScreen(post, true, {}, false, {}) } - return post } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/article/PostContent.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/article/PostContent.kt index 312117d289..82346a135d 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/article/PostContent.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/article/PostContent.kt @@ -16,39 +16,45 @@ package com.example.jetnews.ui.article +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollableColumn import androidx.compose.foundation.background 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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.Colors -import androidx.compose.material.ContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.Typography import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.Typography import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.platform.AmbientDensity +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.SpanStyle @@ -62,6 +68,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.example.jetnews.R import com.example.jetnews.data.posts.impl.post3 import com.example.jetnews.model.Markup import com.example.jetnews.model.MarkupType @@ -69,83 +76,93 @@ import com.example.jetnews.model.Metadata import com.example.jetnews.model.Paragraph import com.example.jetnews.model.ParagraphType import com.example.jetnews.model.Post -import com.example.jetnews.ui.ThemedPreview +import com.example.jetnews.ui.theme.JetnewsTheme private val defaultSpacerSize = 16.dp @Composable -fun PostContent(post: Post, modifier: Modifier = Modifier) { - ScrollableColumn( - modifier = modifier.padding(horizontal = defaultSpacerSize) +fun PostContent( + post: Post, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + state: LazyListState = rememberLazyListState() +) { + LazyColumn( + contentPadding = contentPadding, + modifier = modifier.padding(horizontal = defaultSpacerSize), + state = state, ) { - Spacer(Modifier.preferredHeight(defaultSpacerSize)) + postContentItems(post) + } +} + +fun LazyListScope.postContentItems(post: Post) { + item { PostHeaderImage(post) - Text(text = post.title, style = MaterialTheme.typography.h4) - Spacer(Modifier.preferredHeight(8.dp)) - post.subtitle?.let { subtitle -> - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = subtitle, - style = MaterialTheme.typography.body2, - lineHeight = 20.sp - ) - } - Spacer(Modifier.preferredHeight(defaultSpacerSize)) + Spacer(Modifier.height(defaultSpacerSize)) + Text(post.title, style = MaterialTheme.typography.headlineLarge) + Spacer(Modifier.height(8.dp)) + if (post.subtitle != null) { + Text(post.subtitle, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(defaultSpacerSize)) } - PostMetadata(post.metadata) - Spacer(Modifier.preferredHeight(24.dp)) - PostContents(post.paragraphs) - Spacer(Modifier.preferredHeight(48.dp)) } + item { PostMetadata(post.metadata, Modifier.padding(bottom = 24.dp)) } + items(post.paragraphs) { Paragraph(paragraph = it) } } @Composable private fun PostHeaderImage(post: Post) { - post.image?.let { image -> - val imageModifier = Modifier - .heightIn(min = 180.dp) - .fillMaxWidth() - .clip(shape = MaterialTheme.shapes.medium) - Image(image, imageModifier, contentScale = ContentScale.Crop) - Spacer(Modifier.preferredHeight(defaultSpacerSize)) - } + val imageModifier = Modifier + .heightIn(min = 180.dp) + .fillMaxWidth() + .clip(shape = MaterialTheme.shapes.large) + Image( + painter = painterResource(post.imageId), + contentDescription = null, // decorative + modifier = imageModifier, + contentScale = ContentScale.Crop + ) } @Composable -private fun PostMetadata(metadata: Metadata) { - val typography = MaterialTheme.typography - Row { +private fun PostMetadata( + metadata: Metadata, + modifier: Modifier = Modifier +) { + Row( + // Merge semantics so accessibility services consider this row a single element + modifier = modifier.semantics(mergeDescendants = true) {} + ) { Image( imageVector = Icons.Filled.AccountCircle, - modifier = Modifier.preferredSize(40.dp), - colorFilter = ColorFilter.tint(AmbientContentColor.current), + contentDescription = null, // decorative + modifier = Modifier.size(40.dp), + colorFilter = ColorFilter.tint(LocalContentColor.current), contentScale = ContentScale.Fit ) - Spacer(Modifier.preferredWidth(8.dp)) + Spacer(Modifier.width(8.dp)) Column { Text( text = metadata.author.name, - style = typography.caption, + style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 4.dp) ) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = "${metadata.date} • ${metadata.readTimeMinutes} min read", - style = typography.caption - ) - } + Text( + text = stringResource( + id = R.string.article_post_min_read, + formatArgs = arrayOf( + metadata.date, + metadata.readTimeMinutes + ) + ), + style = MaterialTheme.typography.bodySmall + ) } } } -@Composable -private fun PostContents(paragraphs: List<Paragraph>) { - paragraphs.forEach { - Paragraph(paragraph = it) - } -} - @Composable private fun Paragraph(paragraph: Paragraph) { val (textStyle, paragraphStyle, trailingPadding) = paragraph.type.getTextAndParagraphStyle() @@ -153,7 +170,7 @@ private fun Paragraph(paragraph: Paragraph) { val annotatedString = paragraphToAnnotatedString( paragraph, MaterialTheme.typography, - MaterialTheme.colors.codeBlockBackground + MaterialTheme.colorScheme.codeBlockBackground ) Box(modifier = Modifier.padding(bottom = trailingPadding)) { when (paragraph.type) { @@ -190,7 +207,7 @@ private fun CodeBlockParagraph( paragraphStyle: ParagraphStyle ) { Surface( - color = MaterialTheme.colors.codeBlockBackground, + color = MaterialTheme.colorScheme.codeBlockBackground, shape = MaterialTheme.shapes.small, modifier = Modifier.fillMaxWidth() ) { @@ -209,16 +226,16 @@ private fun BulletParagraph( paragraphStyle: ParagraphStyle ) { Row { - with(AmbientDensity.current) { + with(LocalDensity.current) { // this box is acting as a character, so it's sized with font scaling (sp) Box( modifier = Modifier - .preferredSize(8.sp.toDp(), 8.sp.toDp()) + .size(8.sp.toDp(), 8.sp.toDp()) .alignBy { // Add an alignment "baseline" 1sp below the bottom of the circle - 9.sp.toIntPx() + 9.sp.roundToPx() } - .background(AmbientContentColor.current, CircleShape), + .background(LocalContentColor.current, CircleShape), ) { /* no content */ } } Text( @@ -240,29 +257,28 @@ private data class ParagraphStyling( @Composable private fun ParagraphType.getTextAndParagraphStyle(): ParagraphStyling { val typography = MaterialTheme.typography - var textStyle: TextStyle = typography.body1 + var textStyle: TextStyle = typography.bodyLarge var paragraphStyle = ParagraphStyle() var trailingPadding = 24.dp when (this) { - ParagraphType.Caption -> textStyle = typography.body1 - ParagraphType.Title -> textStyle = typography.h4 + ParagraphType.Caption -> textStyle = typography.labelMedium + ParagraphType.Title -> textStyle = typography.headlineLarge ParagraphType.Subhead -> { - textStyle = typography.h6 + textStyle = typography.headlineSmall trailingPadding = 16.dp } ParagraphType.Text -> { - textStyle = typography.body1 - paragraphStyle = paragraphStyle.copy(lineHeight = 28.sp) + textStyle = typography.bodyLarge.copy(lineHeight = 28.sp) } ParagraphType.Header -> { - textStyle = typography.h5 + textStyle = typography.headlineMedium trailingPadding = 16.dp } - ParagraphType.CodeBlock -> textStyle = typography.body1.copy( + ParagraphType.CodeBlock -> textStyle = typography.bodyLarge.copy( fontFamily = FontFamily.Monospace ) - ParagraphType.Quote -> textStyle = typography.body1 + ParagraphType.Quote -> textStyle = typography.bodyLarge ParagraphType.Bullet -> { paragraphStyle = ParagraphStyle(textIndent = TextIndent(firstLine = 8.sp)) } @@ -291,28 +307,28 @@ fun Markup.toAnnotatedStringItem( return when (this.type) { MarkupType.Italic -> { AnnotatedString.Range( - typography.body1.copy(fontStyle = FontStyle.Italic).toSpanStyle(), + typography.bodyLarge.copy(fontStyle = FontStyle.Italic).toSpanStyle(), start, end ) } MarkupType.Link -> { AnnotatedString.Range( - typography.body1.copy(textDecoration = TextDecoration.Underline).toSpanStyle(), + typography.bodyLarge.copy(textDecoration = TextDecoration.Underline).toSpanStyle(), start, end ) } MarkupType.Bold -> { AnnotatedString.Range( - typography.body1.copy(fontWeight = FontWeight.Bold).toSpanStyle(), + typography.bodyLarge.copy(fontWeight = FontWeight.Bold).toSpanStyle(), start, end ) } MarkupType.Code -> { AnnotatedString.Range( - typography.body1 + typography.bodyLarge .copy( background = codeBlockBackground, fontFamily = FontFamily.Monospace @@ -324,21 +340,16 @@ fun Markup.toAnnotatedStringItem( } } -private val Colors.codeBlockBackground: Color +private val ColorScheme.codeBlockBackground: Color get() = onSurface.copy(alpha = .15f) @Preview("Post content") +@Preview("Post content (dark)", uiMode = UI_MODE_NIGHT_YES) @Composable fun PreviewPost() { - ThemedPreview { - PostContent(post = post3) - } -} - -@Preview("Post content dark theme") -@Composable -fun PreviewPostDark() { - ThemedPreview(darkTheme = true) { - PostContent(post = post3) + JetnewsTheme { + Surface { + PostContent(post = post3) + } } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/components/AppNavRail.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/components/AppNavRail.kt new file mode 100644 index 0000000000..43bf5640d4 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/components/AppNavRail.kt @@ -0,0 +1,88 @@ +/* + * 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.jetnews.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Spacer +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.ListAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetnews.R +import com.example.jetnews.ui.JetnewsDestinations +import com.example.jetnews.ui.theme.JetnewsTheme + +@Composable +fun AppNavRail( + currentRoute: String, + navigateToHome: () -> Unit, + navigateToInterests: () -> Unit, + modifier: Modifier = Modifier +) { + NavigationRail( + header = { + Icon( + painterResource(R.drawable.ic_jetnews_logo), + null, + Modifier.padding(vertical = 12.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + modifier = modifier + ) { + Spacer(Modifier.weight(1f)) + NavigationRailItem( + selected = currentRoute == JetnewsDestinations.HOME_ROUTE, + onClick = navigateToHome, + icon = { Icon(Icons.Filled.Home, stringResource(R.string.home_title)) }, + label = { Text(stringResource(R.string.home_title)) }, + alwaysShowLabel = false + ) + NavigationRailItem( + selected = currentRoute == JetnewsDestinations.INTERESTS_ROUTE, + onClick = navigateToInterests, + icon = { Icon(Icons.Filled.ListAlt, stringResource(R.string.interests_title)) }, + label = { Text(stringResource(R.string.interests_title)) }, + alwaysShowLabel = false + ) + Spacer(Modifier.weight(1f)) + } +} + +@Preview("Drawer contents") +@Preview("Drawer contents (dark)", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun PreviewAppNavRail() { + JetnewsTheme { + AppNavRail( + currentRoute = JetnewsDestinations.HOME_ROUTE, + navigateToHome = {}, + navigateToInterests = {}, + ) + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/components/JetnewsSnackbarHost.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/components/JetnewsSnackbarHost.kt new file mode 100644 index 0000000000..1f46ec4510 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/components/JetnewsSnackbarHost.kt @@ -0,0 +1,49 @@ +/* + * 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.jetnews.ui.components + +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * [SnackbarHost] that is configured for insets and large screens + */ +@Composable +fun JetnewsSnackbarHost( + hostState: SnackbarHostState, + modifier: Modifier = Modifier, + snackbar: @Composable (SnackbarData) -> Unit = { Snackbar(it) } +) { + SnackbarHost( + hostState = hostState, + modifier = modifier + .systemBarsPadding() + // Limit the Snackbar width for large screens + .wrapContentWidth(align = Alignment.Start) + .widthIn(max = 550.dp), + snackbar = snackbar + ) +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt new file mode 100644 index 0000000000..0835169687 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt @@ -0,0 +1,208 @@ +/* + * 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.jetnews.ui.home + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetnews.ui.article.ArticleScreen +import com.example.jetnews.ui.home.HomeScreenType.ArticleDetails +import com.example.jetnews.ui.home.HomeScreenType.Feed +import com.example.jetnews.ui.home.HomeScreenType.FeedWithArticleDetails + +/** + * Displays the Home route. + * + * Note: AAC ViewModels don't work with Compose Previews currently. + * + * @param homeViewModel ViewModel that handles the business logic of this screen + * @param isExpandedScreen (state) whether the screen is expanded + * @param openDrawer (event) request opening the app drawer + * @param snackbarHostState (state) state for the [Scaffold] component on this screen + */ +@Composable +fun HomeRoute( + homeViewModel: HomeViewModel, + isExpandedScreen: Boolean, + openDrawer: () -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } +) { + // UiState of the HomeScreen + val uiState by homeViewModel.uiState.collectAsStateWithLifecycle() + + HomeRoute( + uiState = uiState, + isExpandedScreen = isExpandedScreen, + onToggleFavorite = { homeViewModel.toggleFavourite(it) }, + onSelectPost = { homeViewModel.selectArticle(it) }, + onRefreshPosts = { homeViewModel.refreshPosts() }, + onErrorDismiss = { homeViewModel.errorShown(it) }, + onInteractWithFeed = { homeViewModel.interactedWithFeed() }, + onInteractWithArticleDetails = { homeViewModel.interactedWithArticleDetails(it) }, + onSearchInputChanged = { homeViewModel.onSearchInputChanged(it) }, + openDrawer = openDrawer, + snackbarHostState = snackbarHostState, + ) +} + +/** + * Displays the Home route. + * + * This composable is not coupled to any specific state management. + * + * @param uiState (state) the data to show on the screen + * @param isExpandedScreen (state) whether the screen is expanded + * @param onToggleFavorite (event) toggles favorite for a post + * @param onSelectPost (event) indicate that a post was selected + * @param onRefreshPosts (event) request a refresh of posts + * @param onErrorDismiss (event) error message was shown + * @param onInteractWithFeed (event) indicate that the feed was interacted with + * @param onInteractWithArticleDetails (event) indicate that the article details were interacted + * with + * @param openDrawer (event) request opening the app drawer + * @param snackbarHostState (state) state for the [Scaffold] component on this screen + */ +@Composable +fun HomeRoute( + uiState: HomeUiState, + isExpandedScreen: Boolean, + onToggleFavorite: (String) -> Unit, + onSelectPost: (String) -> Unit, + onRefreshPosts: () -> Unit, + onErrorDismiss: (Long) -> Unit, + onInteractWithFeed: () -> Unit, + onInteractWithArticleDetails: (String) -> Unit, + onSearchInputChanged: (String) -> Unit, + openDrawer: () -> Unit, + snackbarHostState: SnackbarHostState +) { + // Construct the lazy list states for the list and the details outside of deciding which one to + // show. This allows the associated state to survive beyond that decision, and therefore + // we get to preserve the scroll throughout any changes to the content. + val homeListLazyListState = rememberLazyListState() + val articleDetailLazyListStates = when (uiState) { + is HomeUiState.HasPosts -> uiState.postsFeed.allPosts + is HomeUiState.NoPosts -> emptyList() + }.associate { post -> + key(post.id) { + post.id to rememberLazyListState() + } + } + + val homeScreenType = getHomeScreenType(isExpandedScreen, uiState) + when (homeScreenType) { + HomeScreenType.FeedWithArticleDetails -> { + HomeFeedWithArticleDetailsScreen( + uiState = uiState, + showTopAppBar = !isExpandedScreen, + onToggleFavorite = onToggleFavorite, + onSelectPost = onSelectPost, + onRefreshPosts = onRefreshPosts, + onErrorDismiss = onErrorDismiss, + onInteractWithList = onInteractWithFeed, + onInteractWithDetail = onInteractWithArticleDetails, + openDrawer = openDrawer, + homeListLazyListState = homeListLazyListState, + articleDetailLazyListStates = articleDetailLazyListStates, + snackbarHostState = snackbarHostState, + onSearchInputChanged = onSearchInputChanged, + ) + } + HomeScreenType.Feed -> { + HomeFeedScreen( + uiState = uiState, + showTopAppBar = !isExpandedScreen, + onToggleFavorite = onToggleFavorite, + onSelectPost = onSelectPost, + onRefreshPosts = onRefreshPosts, + onErrorDismiss = onErrorDismiss, + openDrawer = openDrawer, + homeListLazyListState = homeListLazyListState, + snackbarHostState = snackbarHostState, + onSearchInputChanged = onSearchInputChanged, + ) + } + HomeScreenType.ArticleDetails -> { + // Guaranteed by above condition for home screen type + check(uiState is HomeUiState.HasPosts) + + ArticleScreen( + post = uiState.selectedPost, + isExpandedScreen = isExpandedScreen, + onBack = onInteractWithFeed, + isFavorite = uiState.favorites.contains(uiState.selectedPost.id), + onToggleFavorite = { + onToggleFavorite(uiState.selectedPost.id) + }, + lazyListState = articleDetailLazyListStates.getValue( + uiState.selectedPost.id + ) + ) + + // If we are just showing the detail, have a back press switch to the list. + // This doesn't take anything more than notifying that we "interacted with the list" + // since that is what drives the display of the feed + BackHandler { + onInteractWithFeed() + } + } + } +} + +/** + * A precise enumeration of which type of screen to display at the home route. + * + * There are 3 options: + * - [FeedWithArticleDetails], which displays both a list of all articles and a specific article. + * - [Feed], which displays just the list of all articles + * - [ArticleDetails], which displays just a specific article. + */ +private enum class HomeScreenType { + FeedWithArticleDetails, + Feed, + ArticleDetails +} + +/** + * Returns the current [HomeScreenType] to display, based on whether or not the screen is expanded + * and the [HomeUiState]. + */ +@Composable +private fun getHomeScreenType( + isExpandedScreen: Boolean, + uiState: HomeUiState +): HomeScreenType = when (isExpandedScreen) { + false -> { + when (uiState) { + is HomeUiState.HasPosts -> { + if (uiState.isArticleOpen) { + HomeScreenType.ArticleDetails + } else { + HomeScreenType.Feed + } + } + is HomeUiState.NoPosts -> HomeScreenType.Feed + } + } + true -> HomeScreenType.FeedWithArticleDetails +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreen.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreen.kt deleted file mode 100644 index 8f03dfe466..0000000000 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreen.kt +++ /dev/null @@ -1,471 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetnews.ui.home - -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.ScrollableRow -import androidx.compose.foundation.clickable -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.layout.preferredSize -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Divider -import androidx.compose.material.DrawerValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.ScaffoldState -import androidx.compose.material.SnackbarResult -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.TopAppBar -import androidx.compose.material.rememberDrawerState -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.AmbientContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.jetnews.R -import com.example.jetnews.data.Result -import com.example.jetnews.data.posts.PostsRepository -import com.example.jetnews.data.posts.impl.BlockingFakePostsRepository -import com.example.jetnews.model.Post -import com.example.jetnews.ui.AppDrawer -import com.example.jetnews.ui.Screen -import com.example.jetnews.ui.SwipeToRefreshLayout -import com.example.jetnews.ui.ThemedPreview -import com.example.jetnews.ui.state.UiState -import com.example.jetnews.utils.produceUiState -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -/** - * Stateful HomeScreen which manages state using [produceUiState] - * - * @param navigateTo (event) request navigation to [Screen] - * @param postsRepository data source for this screen - * @param scaffoldState (state) state for the [Scaffold] component on this screen - */ -@Composable -fun HomeScreen( - navigateTo: (Screen) -> Unit, - postsRepository: PostsRepository, - scaffoldState: ScaffoldState = rememberScaffoldState() -) { - val (postUiState, refreshPost, clearError) = produceUiState(postsRepository) { - getPosts() - } - - // [collectAsState] will automatically collect a Flow<T> and return a State<T> object that - // updates whenever the Flow emits a value. Collection is cancelled when [collectAsState] is - // removed from the composition tree. - val favorites by postsRepository.observeFavorites().collectAsState(setOf()) - - // Returns a [CoroutineScope] that is scoped to the lifecycle of [HomeScreen]. When this - // screen is removed from composition, the scope will be cancelled. - val coroutineScope = rememberCoroutineScope() - - HomeScreen( - posts = postUiState.value, - favorites = favorites, - onToggleFavorite = { - coroutineScope.launch { postsRepository.toggleFavorite(it) } - }, - onRefreshPosts = refreshPost, - onErrorDismiss = clearError, - navigateTo = navigateTo, - scaffoldState = scaffoldState - ) -} - -/** - * Responsible for displaying the Home Screen of this application. - * - * Stateless composable is not coupled to any specific state management. - * - * @param posts (state) the data to show on the screen - * @param favorites (state) favorite posts - * @param onToggleFavorite (event) toggles favorite for a post - * @param onRefreshPosts (event) request a refresh of posts - * @param onErrorDismiss (event) request the current error be dismissed - * @param navigateTo (event) request navigation to [Screen] - */ -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun HomeScreen( - posts: UiState<List<Post>>, - favorites: Set<String>, - onToggleFavorite: (String) -> Unit, - onRefreshPosts: () -> Unit, - onErrorDismiss: () -> Unit, - navigateTo: (Screen) -> Unit, - scaffoldState: ScaffoldState -) { - if (posts.hasError) { - val errorMessage = stringResource(id = R.string.load_error) - val retryMessage = stringResource(id = R.string.retry) - - // If onRefreshPosts or onErrorDismiss change while the LaunchedEffect is running, - // don't restart the effect and use the latest lambda values. - val onRefreshPostsState by rememberUpdatedState(onRefreshPosts) - val onErrorDismissState by rememberUpdatedState(onErrorDismiss) - - // Show snackbar using a coroutine, when the coroutine is cancelled the snackbar will - // automatically dismiss. This coroutine will cancel whenever posts.hasError is false - // (thanks to the surrounding if statement) or if scaffoldState changes. - LaunchedEffect(scaffoldState) { - val snackbarResult = scaffoldState.snackbarHostState.showSnackbar( - message = errorMessage, - actionLabel = retryMessage - ) - when (snackbarResult) { - SnackbarResult.ActionPerformed -> onRefreshPostsState() - SnackbarResult.Dismissed -> onErrorDismissState() - } - } - } - - Scaffold( - scaffoldState = scaffoldState, - drawerContent = { - AppDrawer( - currentScreen = Screen.Home, - closeDrawer = { scaffoldState.drawerState.close() }, - navigateTo = navigateTo - ) - }, - topBar = { - val title = stringResource(id = R.string.app_name) - TopAppBar( - title = { Text(text = title) }, - navigationIcon = { - IconButton(onClick = { scaffoldState.drawerState.open() }) { - Icon(vectorResource(R.drawable.ic_jetnews_logo)) - } - } - ) - }, - bodyContent = { innerPadding -> - val modifier = Modifier.padding(innerPadding) - LoadingContent( - empty = posts.initialLoad, - emptyContent = { FullScreenLoading() }, - loading = posts.loading, - onRefresh = onRefreshPosts, - content = { - HomeScreenErrorAndContent( - posts = posts, - onRefresh = { - onRefreshPosts() - }, - navigateTo = navigateTo, - favorites = favorites, - onToggleFavorite = onToggleFavorite, - modifier = modifier - ) - } - ) - } - ) -} - -/** - * Display an initial empty state or swipe to refresh content. - * - * @param empty (state) when true, display [emptyContent] - * @param emptyContent (slot) the content to display for the empty state - * @param loading (state) when true, display a loading spinner over [content] - * @param onRefresh (event) event to request refresh - * @param content (slot) the main content to show - */ -@Composable -private fun LoadingContent( - empty: Boolean, - emptyContent: @Composable () -> Unit, - loading: Boolean, - onRefresh: () -> Unit, - content: @Composable () -> Unit -) { - if (empty) { - emptyContent() - } else { - SwipeToRefreshLayout( - refreshingState = loading, - onRefresh = onRefresh, - refreshIndicator = { - Surface(elevation = 10.dp, shape = CircleShape) { - CircularProgressIndicator( - modifier = Modifier - .preferredSize(36.dp) - .padding(4.dp) - ) - } - }, - content = content, - ) - } -} - -/** - * Responsible for displaying any error conditions around [PostList]. - * - * @param posts (state) list of posts and error state to display - * @param onRefresh (event) request to refresh data - * @param navigateTo (event) request navigation to [Screen] - * @param favorites (state) all favorites - * @param onToggleFavorite (event) request a single favorite be toggled - * @param modifier modifier for root element - */ -@Composable -private fun HomeScreenErrorAndContent( - posts: UiState<List<Post>>, - onRefresh: () -> Unit, - navigateTo: (Screen) -> Unit, - favorites: Set<String>, - onToggleFavorite: (String) -> Unit, - modifier: Modifier = Modifier -) { - if (posts.data != null) { - PostList(posts.data, navigateTo, favorites, onToggleFavorite, modifier) - } else if (!posts.hasError) { - // if there are no posts, and no error, let the user refresh manually - TextButton(onClick = onRefresh, modifier.fillMaxSize()) { - Text("Tap to load content", textAlign = TextAlign.Center) - } - } else { - // there's currently an error showing, don't show any content - Box(modifier.fillMaxSize()) { /* empty screen */ } - } -} - -/** - * Display a list of posts. - * - * When a post is clicked on, [navigateTo] will be called to navigate to the detail screen for that - * post. - * - * @param posts (state) the list to display - * @param navigateTo (event) request navigation to [Screen] - * @param modifier modifier for the root element - */ -@Composable -private fun PostList( - posts: List<Post>, - navigateTo: (Screen) -> Unit, - favorites: Set<String>, - onToggleFavorite: (String) -> Unit, - modifier: Modifier = Modifier -) { - val postTop = posts[3] - val postsSimple = posts.subList(0, 2) - val postsPopular = posts.subList(2, 7) - val postsHistory = posts.subList(7, 10) - - ScrollableColumn(modifier = modifier) { - PostListTopSection(postTop, navigateTo) - PostListSimpleSection(postsSimple, navigateTo, favorites, onToggleFavorite) - PostListPopularSection(postsPopular, navigateTo) - PostListHistorySection(postsHistory, navigateTo) - } -} - -/** - * Full screen circular progress indicator - */ -@Composable -private fun FullScreenLoading() { - Box(modifier = Modifier.fillMaxSize().wrapContentSize(Alignment.Center)) { - CircularProgressIndicator() - } -} - -/** - * Top section of [PostList] - * - * @param post (state) highlighted post to display - * @param navigateTo (event) request navigation to [Screen] - */ -@Composable -private fun PostListTopSection(post: Post, navigateTo: (Screen) -> Unit) { - Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp), - text = "Top stories for you", - style = MaterialTheme.typography.subtitle1 - ) - PostCardTop( - post = post, - modifier = Modifier.clickable(onClick = { navigateTo(Screen.Article(post.id)) }) - ) - PostListDivider() -} - -/** - * Full-width list items for [PostList] - * - * @param posts (state) to display - * @param navigateTo (event) request navigation to [Screen] - */ -@Composable -private fun PostListSimpleSection( - posts: List<Post>, - navigateTo: (Screen) -> Unit, - favorites: Set<String>, - onToggleFavorite: (String) -> Unit -) { - Column { - posts.forEach { post -> - PostCardSimple( - post = post, - navigateTo = navigateTo, - isFavorite = favorites.contains(post.id), - onToggleFavorite = { onToggleFavorite(post.id) } - ) - PostListDivider() - } - } -} - -/** - * Horizontal scrolling cards for [PostList] - * - * @param posts (state) to display - * @param navigateTo (event) request navigation to [Screen] - */ -@Composable -private fun PostListPopularSection( - posts: List<Post>, - navigateTo: (Screen) -> Unit -) { - Column { - Text( - modifier = Modifier.padding(16.dp), - text = "Popular on Jetnews", - style = MaterialTheme.typography.subtitle1 - ) - - ScrollableRow(modifier = Modifier.padding(end = 16.dp)) { - posts.forEach { post -> - PostCardPopular(post, navigateTo, Modifier.padding(start = 16.dp, bottom = 16.dp)) - } - } - PostListDivider() - } -} - -/** - * Full-width list items that display "based on your history" for [PostList] - * - * @param posts (state) to display - * @param navigateTo (event) request navigation to [Screen] - */ -@Composable -private fun PostListHistorySection( - posts: List<Post>, - navigateTo: (Screen) -> Unit -) { - Column { - posts.forEach { post -> - PostCardHistory(post, navigateTo) - PostListDivider() - } - } -} - -/** - * Full-width divider with padding for [PostList] - */ -@Composable -private fun PostListDivider() { - Divider( - modifier = Modifier.padding(horizontal = 14.dp), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f) - ) -} - -@Preview("Home screen body") -@Composable -fun PreviewHomeScreenBody() { - ThemedPreview { - val posts = loadFakePosts() - PostList(posts, { }, setOf(), {}) - } -} - -@Preview("Home screen, open drawer") -@Composable -private fun PreviewDrawerOpen() { - ThemedPreview { - val scaffoldState = rememberScaffoldState( - drawerState = rememberDrawerState(DrawerValue.Open) - ) - HomeScreen( - postsRepository = BlockingFakePostsRepository(AmbientContext.current), - scaffoldState = scaffoldState, - navigateTo = { } - ) - } -} - -@Preview("Home screen dark theme") -@Composable -fun PreviewHomeScreenBodyDark() { - ThemedPreview(darkTheme = true) { - val posts = loadFakePosts() - PostList(posts, {}, setOf(), {}) - } -} - -@Composable -private fun loadFakePosts(): List<Post> { - val context = AmbientContext.current - val posts = runBlocking { - BlockingFakePostsRepository(context).getPosts() - } - return (posts as Result.Success).data -} - -@Preview("Home screen, open drawer dark theme") -@Composable -private fun PreviewDrawerOpenDark() { - ThemedPreview(darkTheme = true) { - val scaffoldState = rememberScaffoldState( - drawerState = rememberDrawerState(DrawerValue.Open) - ) - HomeScreen( - postsRepository = BlockingFakePostsRepository(AmbientContext.current), - scaffoldState = scaffoldState, - navigateTo = { } - ) - } -} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt new file mode 100644 index 0000000000..113e8f790e --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt @@ -0,0 +1,843 @@ +/* + * 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.jetnews.ui.home + +import android.content.Context +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.widget.Toast +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.TopAppBarState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.example.jetnews.R +import com.example.jetnews.data.Result +import com.example.jetnews.data.posts.impl.BlockingFakePostsRepository +import com.example.jetnews.model.Post +import com.example.jetnews.model.PostsFeed +import com.example.jetnews.ui.article.postContentItems +import com.example.jetnews.ui.article.sharePost +import com.example.jetnews.ui.components.JetnewsSnackbarHost +import com.example.jetnews.ui.modifiers.interceptKey +import com.example.jetnews.ui.theme.JetnewsTheme +import com.example.jetnews.ui.utils.BookmarkButton +import com.example.jetnews.ui.utils.FavoriteButton +import com.example.jetnews.ui.utils.ShareButton +import com.example.jetnews.ui.utils.TextSettingsButton +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking + +/** + * The home screen displaying the feed along with an article details. + */ +@Composable +fun HomeFeedWithArticleDetailsScreen( + uiState: HomeUiState, + showTopAppBar: Boolean, + onToggleFavorite: (String) -> Unit, + onSelectPost: (String) -> Unit, + onRefreshPosts: () -> Unit, + onErrorDismiss: (Long) -> Unit, + onInteractWithList: () -> Unit, + onInteractWithDetail: (String) -> Unit, + openDrawer: () -> Unit, + homeListLazyListState: LazyListState, + articleDetailLazyListStates: Map<String, LazyListState>, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, + onSearchInputChanged: (String) -> Unit, +) { + HomeScreenWithList( + uiState = uiState, + showTopAppBar = showTopAppBar, + onRefreshPosts = onRefreshPosts, + onErrorDismiss = onErrorDismiss, + openDrawer = openDrawer, + snackbarHostState = snackbarHostState, + modifier = modifier, + ) { hasPostsUiState, contentPadding, contentModifier -> + Row(contentModifier) { + PostList( + postsFeed = hasPostsUiState.postsFeed, + favorites = hasPostsUiState.favorites, + showExpandedSearch = !showTopAppBar, + onArticleTapped = onSelectPost, + onToggleFavorite = onToggleFavorite, + contentPadding = contentPadding, + modifier = Modifier + .width(334.dp) + .notifyInput(onInteractWithList), + state = homeListLazyListState, + searchInput = hasPostsUiState.searchInput, + onSearchInputChanged = onSearchInputChanged, + ) + // Crossfade between different detail posts + Crossfade( + targetState = hasPostsUiState.selectedPost, + label = "Detail Post Crossfade" + ) { detailPost -> + // Get the lazy list state for this detail view + val detailLazyListState by remember { + derivedStateOf { + articleDetailLazyListStates.getValue(detailPost.id) + } + } + + // Key against the post id to avoid sharing any state between different posts + key(detailPost.id) { + Box { + LazyColumn( + state = detailLazyListState, + contentPadding = contentPadding, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxSize() + .notifyInput { + onInteractWithDetail(detailPost.id) + } + ) { + postContentItems(detailPost) + } + + // Floating toolbar + val context = LocalContext.current + PostTopBar( + isFavorite = hasPostsUiState.favorites.contains(detailPost.id), + onToggleFavorite = { onToggleFavorite(detailPost.id) }, + onSharePost = { sharePost(detailPost, context) }, + modifier = Modifier + .windowInsetsPadding(WindowInsets.safeDrawing) + .fillMaxWidth() + .wrapContentWidth(Alignment.End) + ) + } + } + } + } + } +} + +/** + * A [Modifier] that tracks all input, and calls [block] every time input is received. + */ +private fun Modifier.notifyInput(block: () -> Unit): Modifier = + composed { + val blockState = rememberUpdatedState(block) + pointerInput(Unit) { + while (currentCoroutineContext().isActive) { + awaitPointerEventScope { + awaitPointerEvent(PointerEventPass.Initial) + blockState.value() + } + } + } + } + +/** + * The home screen displaying just the article feed. + */ +@Composable +fun HomeFeedScreen( + uiState: HomeUiState, + showTopAppBar: Boolean, + onToggleFavorite: (String) -> Unit, + onSelectPost: (String) -> Unit, + onRefreshPosts: () -> Unit, + onErrorDismiss: (Long) -> Unit, + openDrawer: () -> Unit, + homeListLazyListState: LazyListState, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, + searchInput: String = "", + onSearchInputChanged: (String) -> Unit, +) { + HomeScreenWithList( + uiState = uiState, + showTopAppBar = showTopAppBar, + onRefreshPosts = onRefreshPosts, + onErrorDismiss = onErrorDismiss, + openDrawer = openDrawer, + snackbarHostState = snackbarHostState, + modifier = modifier + ) { hasPostsUiState, contentPadding, contentModifier -> + PostList( + postsFeed = hasPostsUiState.postsFeed, + favorites = hasPostsUiState.favorites, + showExpandedSearch = !showTopAppBar, + onArticleTapped = onSelectPost, + onToggleFavorite = onToggleFavorite, + contentPadding = contentPadding, + modifier = contentModifier, + state = homeListLazyListState, + searchInput = searchInput, + onSearchInputChanged = onSearchInputChanged + ) + } +} + +/** + * A display of the home screen that has the list. + * + * This sets up the scaffold with the top app bar, and surrounds the [hasPostsContent] with refresh, + * loading and error handling. + * + * This helper functions exists because [HomeFeedWithArticleDetailsScreen] and [HomeFeedScreen] are + * extremely similar, except for the rendered content when there are posts to display. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeScreenWithList( + uiState: HomeUiState, + showTopAppBar: Boolean, + onRefreshPosts: () -> Unit, + onErrorDismiss: (Long) -> Unit, + openDrawer: () -> Unit, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, + hasPostsContent: @Composable ( + uiState: HomeUiState.HasPosts, + contentPadding: PaddingValues, + modifier: Modifier + ) -> Unit +) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) + Scaffold( + snackbarHost = { JetnewsSnackbarHost(hostState = snackbarHostState) }, + topBar = { + if (showTopAppBar) { + HomeTopAppBar( + openDrawer = openDrawer, + topAppBarState = topAppBarState + ) + } + }, + modifier = modifier + ) { innerPadding -> + val contentModifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + + LoadingContent( + modifier = Modifier.padding(innerPadding), + empty = when (uiState) { + is HomeUiState.HasPosts -> false + is HomeUiState.NoPosts -> uiState.isLoading + }, + emptyContent = { FullScreenLoading() }, + loading = uiState.isLoading, + onRefresh = onRefreshPosts, + content = { + when (uiState) { + is HomeUiState.HasPosts -> + hasPostsContent(uiState, innerPadding, contentModifier) + + is HomeUiState.NoPosts -> { + if (uiState.errorMessages.isEmpty()) { + // if there are no posts, and no error, let the user refresh manually + TextButton( + onClick = onRefreshPosts, + modifier + .padding(innerPadding) + .fillMaxSize() + ) { + Text( + stringResource(id = R.string.home_tap_to_load_content), + textAlign = TextAlign.Center + ) + } + } else { + // there's currently an error showing, don't show any content + Box( + contentModifier + .padding(innerPadding) + .fillMaxSize() + ) { /* empty screen */ } + } + } + } + } + ) + } + + // Process one error message at a time and show them as Snackbars in the UI + if (uiState.errorMessages.isNotEmpty()) { + // Remember the errorMessage to display on the screen + val errorMessage = remember(uiState) { uiState.errorMessages[0] } + + // Get the text to show on the message from resources + val errorMessageText: String = stringResource(errorMessage.messageId) + val retryMessageText = stringResource(id = R.string.retry) + + // If onRefreshPosts or onErrorDismiss change while the LaunchedEffect is running, + // don't restart the effect and use the latest lambda values. + val onRefreshPostsState by rememberUpdatedState(onRefreshPosts) + val onErrorDismissState by rememberUpdatedState(onErrorDismiss) + + // Effect running in a coroutine that displays the Snackbar on the screen + // If there's a change to errorMessageText, retryMessageText or snackbarHostState, + // the previous effect will be cancelled and a new one will start with the new values + LaunchedEffect(errorMessageText, retryMessageText, snackbarHostState) { + val snackbarResult = snackbarHostState.showSnackbar( + message = errorMessageText, + actionLabel = retryMessageText + ) + if (snackbarResult == SnackbarResult.ActionPerformed) { + onRefreshPostsState() + } + // Once the message is displayed and dismissed, notify the ViewModel + onErrorDismissState(errorMessage.id) + } + } +} + +/** + * Display an initial empty state or swipe to refresh content. + * + * @param empty (state) when true, display [emptyContent] + * @param emptyContent (slot) the content to display for the empty state + * @param loading (state) when true, display a loading spinner over [content] + * @param onRefresh (event) event to request refresh + * @param content (slot) the main content to show + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LoadingContent( + empty: Boolean, + emptyContent: @Composable () -> Unit, + loading: Boolean, + onRefresh: () -> Unit, + content: @Composable () -> Unit, + modifier: Modifier = Modifier +) { + if (empty) { + emptyContent() + } else { + val refreshState = rememberPullToRefreshState() + PullToRefreshBox( + isRefreshing = loading, + onRefresh = onRefresh, + content = { content() }, + state = refreshState, + indicator = { + Indicator( + modifier = modifier + .align(Alignment.TopCenter) + .padding(), + isRefreshing = loading, + state = refreshState + ) + } + ) + } +} + +/** + * Display a feed of posts. + * + * When a post is clicked on, [onArticleTapped] will be called. + * + * @param postsFeed (state) the feed to display + * @param onArticleTapped (event) request navigation to Article screen + * @param modifier modifier for the root element + */ +@Composable +private fun PostList( + postsFeed: PostsFeed, + favorites: Set<String>, + showExpandedSearch: Boolean, + onArticleTapped: (postId: String) -> Unit, + onToggleFavorite: (String) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + state: LazyListState = rememberLazyListState(), + searchInput: String = "", + onSearchInputChanged: (String) -> Unit, +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + state = state + ) { + if (showExpandedSearch) { + item { + HomeSearch( + Modifier.padding(horizontal = 16.dp), + searchInput = searchInput, + onSearchInputChanged = onSearchInputChanged, + ) + } + } + item { PostListTopSection(postsFeed.highlightedPost, onArticleTapped) } + if (postsFeed.recommendedPosts.isNotEmpty()) { + item { + PostListSimpleSection( + postsFeed.recommendedPosts, + onArticleTapped, + favorites, + onToggleFavorite + ) + } + } + if (postsFeed.popularPosts.isNotEmpty() && !showExpandedSearch) { + item { + PostListPopularSection( + postsFeed.popularPosts, onArticleTapped + ) + } + } + if (postsFeed.recentPosts.isNotEmpty()) { + item { PostListHistorySection(postsFeed.recentPosts, onArticleTapped) } + } + } +} + +/** + * Full screen circular progress indicator + */ +@Composable +private fun FullScreenLoading() { + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) { + CircularProgressIndicator() + } +} + +/** + * Top section of [PostList] + * + * @param post (state) highlighted post to display + * @param navigateToArticle (event) request navigation to Article screen + */ +@Composable +private fun PostListTopSection(post: Post, navigateToArticle: (String) -> Unit) { + Text( + modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp), + text = stringResource(id = R.string.home_top_section_title), + style = MaterialTheme.typography.titleMedium + ) + PostCardTop( + post = post, + modifier = Modifier.clickable(onClick = { navigateToArticle(post.id) }) + ) + PostListDivider() +} + +/** + * Full-width list items for [PostList] + * + * @param posts (state) to display + * @param navigateToArticle (event) request navigation to Article screen + */ +@Composable +private fun PostListSimpleSection( + posts: List<Post>, + navigateToArticle: (String) -> Unit, + favorites: Set<String>, + onToggleFavorite: (String) -> Unit +) { + Column { + posts.forEach { post -> + PostCardSimple( + post = post, + navigateToArticle = navigateToArticle, + isFavorite = favorites.contains(post.id), + onToggleFavorite = { onToggleFavorite(post.id) } + ) + PostListDivider() + } + } +} + +/** + * Horizontal scrolling cards for [PostList] + * + * @param posts (state) to display + * @param navigateToArticle (event) request navigation to Article screen + */ +@Composable +private fun PostListPopularSection( + posts: List<Post>, + navigateToArticle: (String) -> Unit +) { + Column { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(id = R.string.home_popular_section_title), + style = MaterialTheme.typography.titleLarge + ) + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .height(IntrinsicSize.Max) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + for (post in posts) { + PostCardPopular( + post, + navigateToArticle + ) + } + } + Spacer(Modifier.height(16.dp)) + PostListDivider() + } +} + +/** + * Full-width list items that display "based on your history" for [PostList] + * + * @param posts (state) to display + * @param navigateToArticle (event) request navigation to Article screen + */ +@Composable +private fun PostListHistorySection( + posts: List<Post>, + navigateToArticle: (String) -> Unit +) { + Column { + posts.forEach { post -> + PostCardHistory(post, navigateToArticle) + PostListDivider() + } + } +} + +/** + * Full-width divider with padding for [PostList] + */ +@Composable +private fun PostListDivider() { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 14.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + ) +} + +/** + * Expanded search UI - includes support for enter-to-send on the search field + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun HomeSearch( + modifier: Modifier = Modifier, + searchInput: String = "", + onSearchInputChanged: (String) -> Unit, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + value = searchInput, + onValueChange = onSearchInputChanged, + placeholder = { Text(stringResource(R.string.home_search)) }, + leadingIcon = { Icon(Icons.Filled.Search, null) }, + modifier = modifier + .fillMaxWidth() + .interceptKey(Key.Enter) { + // submit a search query when Enter is pressed + submitSearch(onSearchInputChanged, context) + keyboardController?.hide() + focusManager.clearFocus(force = true) + }, + singleLine = true, + // keyboardOptions change the newline key to a search key on the soft keyboard + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + // keyboardActions submits the search query when the search key is pressed + keyboardActions = KeyboardActions( + onSearch = { + submitSearch(onSearchInputChanged, context) + keyboardController?.hide() + } + ) + ) +} + +/** + * Stub helper function to submit a user's search query + */ +private fun submitSearch( + onSearchInputChanged: (String) -> Unit, + context: Context +) { + onSearchInputChanged("") + Toast.makeText( + context, + "Search is not yet implemented", + Toast.LENGTH_SHORT + ).show() +} + +/** + * Top bar for a Post when displayed next to the Home feed + */ +@Composable +private fun PostTopBar( + isFavorite: Boolean, + onToggleFavorite: () -> Unit, + onSharePost: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(8.dp), + border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.onSurface.copy(alpha = .6f)), + modifier = modifier.padding(end = 16.dp) + ) { + Row(Modifier.padding(horizontal = 8.dp)) { + FavoriteButton(onClick = { /* Functionality not available */ }) + BookmarkButton(isBookmarked = isFavorite, onClick = onToggleFavorite) + ShareButton(onClick = onSharePost) + TextSettingsButton(onClick = { /* Functionality not available */ }) + } + } +} + +/** + * TopAppBar for the Home screen + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeTopAppBar( + openDrawer: () -> Unit, + modifier: Modifier = Modifier, + topAppBarState: TopAppBarState = rememberTopAppBarState(), + scrollBehavior: TopAppBarScrollBehavior? = + TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState) +) { + val context = LocalContext.current + val title = stringResource(id = R.string.app_name) + CenterAlignedTopAppBar( + title = { + Image( + painter = painterResource(R.drawable.ic_jetnews_wordmark), + contentDescription = title, + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth() + ) + }, + navigationIcon = { + IconButton(onClick = openDrawer) { + Icon( + painter = painterResource(R.drawable.ic_jetnews_logo), + contentDescription = stringResource(R.string.cd_open_navigation_drawer) + ) + } + }, + actions = { + IconButton(onClick = { + Toast.makeText( + context, + "Search is not yet implemented in this configuration", + Toast.LENGTH_LONG + ).show() + }) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.cd_search) + ) + } + }, + scrollBehavior = scrollBehavior, + modifier = modifier + ) +} + +@Preview("Home list drawer screen") +@Preview("Home list drawer screen (dark)", uiMode = UI_MODE_NIGHT_YES) +@Preview("Home list drawer screen (big font)", fontScale = 1.5f) +@Composable +fun PreviewHomeListDrawerScreen() { + val postsFeed = runBlocking { + (BlockingFakePostsRepository().getPostsFeed() as Result.Success).data + } + JetnewsTheme { + HomeFeedScreen( + uiState = HomeUiState.HasPosts( + postsFeed = postsFeed, + selectedPost = postsFeed.highlightedPost, + isArticleOpen = false, + favorites = emptySet(), + isLoading = false, + errorMessages = emptyList(), + searchInput = "" + ), + showTopAppBar = false, + onToggleFavorite = {}, + onSelectPost = {}, + onRefreshPosts = {}, + onErrorDismiss = {}, + openDrawer = {}, + homeListLazyListState = rememberLazyListState(), + snackbarHostState = SnackbarHostState(), + onSearchInputChanged = {} + ) + } +} + +@Preview("Home list navrail screen", device = Devices.NEXUS_7_2013) +@Preview( + "Home list navrail screen (dark)", + uiMode = UI_MODE_NIGHT_YES, + device = Devices.NEXUS_7_2013 +) +@Preview("Home list navrail screen (big font)", fontScale = 1.5f, device = Devices.NEXUS_7_2013) +@Composable +fun PreviewHomeListNavRailScreen() { + val postsFeed = runBlocking { + (BlockingFakePostsRepository().getPostsFeed() as Result.Success).data + } + JetnewsTheme { + HomeFeedScreen( + uiState = HomeUiState.HasPosts( + postsFeed = postsFeed, + selectedPost = postsFeed.highlightedPost, + isArticleOpen = false, + favorites = emptySet(), + isLoading = false, + errorMessages = emptyList(), + searchInput = "" + ), + showTopAppBar = true, + onToggleFavorite = {}, + onSelectPost = {}, + onRefreshPosts = {}, + onErrorDismiss = {}, + openDrawer = {}, + homeListLazyListState = rememberLazyListState(), + snackbarHostState = SnackbarHostState(), + onSearchInputChanged = {} + ) + } +} + +@Preview("Home list detail screen", device = Devices.PIXEL_C) +@Preview("Home list detail screen (dark)", uiMode = UI_MODE_NIGHT_YES, device = Devices.PIXEL_C) +@Preview("Home list detail screen (big font)", fontScale = 1.5f, device = Devices.PIXEL_C) +@Composable +fun PreviewHomeListDetailScreen() { + val postsFeed = runBlocking { + (BlockingFakePostsRepository().getPostsFeed() as Result.Success).data + } + JetnewsTheme { + HomeFeedWithArticleDetailsScreen( + uiState = HomeUiState.HasPosts( + postsFeed = postsFeed, + selectedPost = postsFeed.highlightedPost, + isArticleOpen = false, + favorites = emptySet(), + isLoading = false, + errorMessages = emptyList(), + searchInput = "" + ), + showTopAppBar = true, + onToggleFavorite = {}, + onSelectPost = {}, + onRefreshPosts = {}, + onErrorDismiss = {}, + onInteractWithList = {}, + onInteractWithDetail = {}, + openDrawer = {}, + homeListLazyListState = rememberLazyListState(), + articleDetailLazyListStates = postsFeed.allPosts.associate { post -> + key(post.id) { + post.id to rememberLazyListState() + } + }, + snackbarHostState = SnackbarHostState(), + onSearchInputChanged = {} + ) + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeViewModel.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeViewModel.kt new file mode 100644 index 0000000000..c112b7f1a8 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeViewModel.kt @@ -0,0 +1,249 @@ +/* + * 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.jetnews.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.jetnews.R +import com.example.jetnews.data.Result +import com.example.jetnews.data.posts.PostsRepository +import com.example.jetnews.model.Post +import com.example.jetnews.model.PostsFeed +import com.example.jetnews.utils.ErrorMessage +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * UI state for the Home route. + * + * This is derived from [HomeViewModelState], but split into two possible subclasses to more + * precisely represent the state available to render the UI. + */ +sealed interface HomeUiState { + + val isLoading: Boolean + val errorMessages: List<ErrorMessage> + val searchInput: String + + /** + * There are no posts to render. + * + * This could either be because they are still loading or they failed to load, and we are + * waiting to reload them. + */ + data class NoPosts( + override val isLoading: Boolean, + override val errorMessages: List<ErrorMessage>, + override val searchInput: String + ) : HomeUiState + + /** + * There are posts to render, as contained in [postsFeed]. + * + * There is guaranteed to be a [selectedPost], which is one of the posts from [postsFeed]. + */ + data class HasPosts( + val postsFeed: PostsFeed, + val selectedPost: Post, + val isArticleOpen: Boolean, + val favorites: Set<String>, + override val isLoading: Boolean, + override val errorMessages: List<ErrorMessage>, + override val searchInput: String + ) : HomeUiState +} + +/** + * An internal representation of the Home route state, in a raw form + */ +private data class HomeViewModelState( + val postsFeed: PostsFeed? = null, + val selectedPostId: String? = null, // TODO back selectedPostId in a SavedStateHandle + val isArticleOpen: Boolean = false, + val favorites: Set<String> = emptySet(), + val isLoading: Boolean = false, + val errorMessages: List<ErrorMessage> = emptyList(), + val searchInput: String = "", +) { + + /** + * Converts this [HomeViewModelState] into a more strongly typed [HomeUiState] for driving + * the ui. + */ + fun toUiState(): HomeUiState = + if (postsFeed == null) { + HomeUiState.NoPosts( + isLoading = isLoading, + errorMessages = errorMessages, + searchInput = searchInput + ) + } else { + HomeUiState.HasPosts( + postsFeed = postsFeed, + // Determine the selected post. This will be the post the user last selected. + // If there is none (or that post isn't in the current feed), default to the + // highlighted post + selectedPost = postsFeed.allPosts.find { + it.id == selectedPostId + } ?: postsFeed.highlightedPost, + isArticleOpen = isArticleOpen, + favorites = favorites, + isLoading = isLoading, + errorMessages = errorMessages, + searchInput = searchInput + ) + } +} + +/** + * ViewModel that handles the business logic of the Home screen + */ +class HomeViewModel( + private val postsRepository: PostsRepository, + preSelectedPostId: String? +) : ViewModel() { + + private val viewModelState = MutableStateFlow( + HomeViewModelState( + isLoading = true, + selectedPostId = preSelectedPostId, + isArticleOpen = preSelectedPostId != null + ) + ) + + // UI state exposed to the UI + val uiState = viewModelState + .map(HomeViewModelState::toUiState) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + viewModelState.value.toUiState() + ) + + init { + refreshPosts() + + // Observe for favorite changes in the repo layer + viewModelScope.launch { + postsRepository.observeFavorites().collect { favorites -> + viewModelState.update { it.copy(favorites = favorites) } + } + } + } + + /** + * Refresh posts and update the UI state accordingly + */ + fun refreshPosts() { + // Ui state is refreshing + viewModelState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + val result = postsRepository.getPostsFeed() + viewModelState.update { + when (result) { + is Result.Success -> it.copy(postsFeed = result.data, isLoading = false) + is Result.Error -> { + val errorMessages = it.errorMessages + ErrorMessage( + id = UUID.randomUUID().mostSignificantBits, + messageId = R.string.load_error + ) + it.copy(errorMessages = errorMessages, isLoading = false) + } + } + } + } + } + + /** + * Toggle favorite of a post + */ + fun toggleFavourite(postId: String) { + viewModelScope.launch { + postsRepository.toggleFavorite(postId) + } + } + + /** + * Selects the given article to view more information about it. + */ + fun selectArticle(postId: String) { + // Treat selecting a detail as simply interacting with it + interactedWithArticleDetails(postId) + } + + /** + * Notify that an error was displayed on the screen + */ + fun errorShown(errorId: Long) { + viewModelState.update { currentUiState -> + val errorMessages = currentUiState.errorMessages.filterNot { it.id == errorId } + currentUiState.copy(errorMessages = errorMessages) + } + } + + /** + * Notify that the user interacted with the feed + */ + fun interactedWithFeed() { + viewModelState.update { + it.copy(isArticleOpen = false) + } + } + + /** + * Notify that the user interacted with the article details + */ + fun interactedWithArticleDetails(postId: String) { + viewModelState.update { + it.copy( + selectedPostId = postId, + isArticleOpen = true + ) + } + } + + /** + * Notify that the user updated the search query + */ + fun onSearchInputChanged(searchInput: String) { + viewModelState.update { + it.copy(searchInput = searchInput) + } + } + + /** + * Factory for HomeViewModel that takes PostsRepository as a dependency + */ + companion object { + fun provideFactory( + postsRepository: PostsRepository, + preSelectedPostId: String? = null + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun <T : ViewModel> create(modelClass: Class<T>): T { + return HomeViewModel(postsRepository, preSelectedPostId) as T + } + } + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardTop.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardTop.kt index 135d2c3f86..9267c0f3d2 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardTop.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardTop.kt @@ -20,105 +20,98 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.ContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.AmbientContext +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.jetnews.data.posts.impl.getPostsWithImagesLoaded -import com.example.jetnews.data.posts.impl.post2 +import com.example.jetnews.R import com.example.jetnews.data.posts.impl.posts import com.example.jetnews.model.Post -import com.example.jetnews.ui.ThemedPreview +import com.example.jetnews.ui.theme.JetnewsTheme +import com.example.jetnews.utils.CompletePreviews @Composable fun PostCardTop(post: Post, modifier: Modifier = Modifier) { // TUTORIAL CONTENT STARTS HERE val typography = MaterialTheme.typography - Column(modifier = modifier.fillMaxWidth().padding(16.dp)) { - post.image?.let { image -> - val imageModifier = Modifier - .heightIn(min = 180.dp) - .fillMaxWidth() - .clip(shape = MaterialTheme.shapes.medium) - Image(image, modifier = imageModifier, contentScale = ContentScale.Crop) - } - Spacer(Modifier.preferredHeight(16.dp)) + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + ) { + val imageModifier = Modifier + .heightIn(min = 180.dp) + .fillMaxWidth() + .clip(shape = MaterialTheme.shapes.large) + Image( + painter = painterResource(post.imageId), + contentDescription = null, // decorative + modifier = imageModifier, + contentScale = ContentScale.Crop + ) + Spacer(Modifier.height(16.dp)) Text( text = post.title, - style = typography.h6 + style = typography.titleLarge, + modifier = Modifier.padding(bottom = 8.dp) ) Text( text = post.metadata.author.name, - style = typography.body2 + style = typography.labelLarge, + modifier = Modifier.padding(bottom = 4.dp) + ) + Text( + text = stringResource( + id = R.string.home_post_min_read, + formatArgs = arrayOf( + post.metadata.date, + post.metadata.readTimeMinutes + ) + ), + style = typography.bodySmall ) - - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = "${post.metadata.date} - ${post.metadata.readTimeMinutes} min read", - style = typography.body2 - ) - } } } // TUTORIAL CONTENT ENDS HERE -// Preview section - -@Preview("Default colors") -@Composable -fun TutorialPreview() { - TutorialPreviewTemplate() -} - -@Preview("Dark theme") -@Composable -fun TutorialPreviewDark() { - TutorialPreviewTemplate(darkTheme = true) -} - -@Preview("Font scaling 1.5", fontScale = 1.5f) -@Composable -fun TutorialPreviewFontscale() { - TutorialPreviewTemplate() -} - -@Composable -fun TutorialPreviewTemplate( - darkTheme: Boolean = false -) { - val context = AmbientContext.current - val previewPosts = getPostsWithImagesLoaded(posts.subList(1, 2), context.resources) - val post = previewPosts[0] - - ThemedPreview(darkTheme) { - PostCardTop(post) - } -} - -@Preview("Post card top") +/** + * Preview of the [PostCardTop] composable. Fake data is passed into the composable. + * + * Learn more about Preview features in the [documentation](https://d.android.com/jetpack/compose/tooling#preview) + */ +@Preview @Composable -fun PreviewPostCardTop() { - ThemedPreview { - PostCardTop(post = post2) +fun PostCardTopPreview() { + JetnewsTheme { + Surface { + PostCardTop(posts.highlightedPost) + } } } -@Preview("Post card top dark theme") +/* + * These previews will only show up on Android Studio Dolphin and later. + * They showcase a feature called Multipreview Annotations. + * + * Read more in the [documentation](https://d.android.com/jetpack/compose/tooling#preview-multipreview) +*/ +@CompletePreviews @Composable -fun PreviewPostCardTopDark() { - ThemedPreview(darkTheme = true) { - PostCardTop(post = post2) +fun PostCardTopPreviews() { + JetnewsTheme { + Surface { + PostCardTop(posts.highlightedPost) + } } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardYourNetwork.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardYourNetwork.kt index 94a7651c87..aa8569cec6 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardYourNetwork.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardYourNetwork.kt @@ -16,69 +16,86 @@ package com.example.jetnews.ui.home +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.material.Card -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.imageResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.example.jetnews.R import com.example.jetnews.data.posts.impl.post1 +import com.example.jetnews.data.posts.impl.post2 +import com.example.jetnews.data.posts.impl.post3 +import com.example.jetnews.data.posts.impl.post4 +import com.example.jetnews.data.posts.impl.post5 import com.example.jetnews.model.Post import com.example.jetnews.model.PostAuthor -import com.example.jetnews.ui.Screen -import com.example.jetnews.ui.ThemedPreview +import com.example.jetnews.ui.theme.JetnewsTheme +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PostCardPopular( post: Post, - navigateTo: (Screen) -> Unit, + navigateToArticle: (String) -> Unit, modifier: Modifier = Modifier ) { Card( + onClick = { navigateToArticle(post.id) }, shape = MaterialTheme.shapes.medium, - modifier = modifier.preferredSize(280.dp, 240.dp) + modifier = modifier + .width(280.dp) ) { - Column(modifier = Modifier.clickable(onClick = { navigateTo(Screen.Article(post.id)) })) { - val image = post.image ?: imageResource(R.drawable.placeholder_4_3) - + Column { Image( - bitmap = image, + painter = painterResource(post.imageId), + contentDescription = null, // decorative contentScale = ContentScale.Crop, modifier = Modifier - .preferredHeight(100.dp) + .height(100.dp) .fillMaxWidth() ) Column(modifier = Modifier.padding(16.dp)) { Text( text = post.title, - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.headlineSmall, maxLines = 2, overflow = TextOverflow.Ellipsis ) + Spacer(modifier = Modifier.weight(1f)) Text( text = post.metadata.author.name, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.body2 + style = MaterialTheme.typography.bodyMedium ) Text( - text = "${post.metadata.date} - " + - "${post.metadata.readTimeMinutes} min read", - style = MaterialTheme.typography.body2 + text = stringResource( + id = R.string.home_post_min_read, + formatArgs = arrayOf( + post.metadata.date, + post.metadata.readTimeMinutes + ) + ), + style = MaterialTheme.typography.bodySmall ) } } @@ -86,24 +103,23 @@ fun PostCardPopular( } @Preview("Regular colors") +@Preview("Dark colors", uiMode = UI_MODE_NIGHT_YES) @Composable -fun PreviewPostCardPopular() { - ThemedPreview { - PostCardPopular(post1, {}) - } -} - -@Preview("Dark colors") -@Composable -fun PreviewPostCardPopularDark() { - ThemedPreview(darkTheme = true) { - PostCardPopular(post1, {}) +fun PreviewPostCardPopular( + @PreviewParameter(PostPreviewParameterProvider::class, limit = 1) post: Post +) { + JetnewsTheme { + Surface { + PostCardPopular(post, {}) + } } } @Preview("Regular colors, long text") @Composable -fun PreviewPostCardPopularLongText() { +fun PreviewPostCardPopularLongText( + @PreviewParameter(PostPreviewParameterProvider::class, limit = 1) post: Post +) { val loremIpsum = """ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras ullamcorper pharetra massa, @@ -113,16 +129,42 @@ fun PreviewPostCardPopularLongText() { facilisis eget magna quis, rhoncus volutpat mi. Phasellus vel sollicitudin quam, eu consectetur dolor. Proin lobortis venenatis sem, in vestibulum est. Duis ac nibh interdum, """.trimIndent() - ThemedPreview { - PostCardPopular( - post1.copy( - title = "Title$loremIpsum", - metadata = post1.metadata.copy( - author = PostAuthor("Author: $loremIpsum"), - readTimeMinutes = Int.MAX_VALUE - ) - ), - {} - ) + JetnewsTheme { + Surface { + PostCardPopular( + post.copy( + title = "Title$loremIpsum", + metadata = post.metadata.copy( + author = PostAuthor("Author: $loremIpsum"), + readTimeMinutes = Int.MAX_VALUE + ) + ), + {} + ) + } } } + +/** + * Provides sample [Post] instances for Composable Previews. + * + * When creating a Composable Preview using @Preview, you can pass sample data + * by annotating a parameter with @PreviewParameter: + * + * ``` + * @Preview + * @Composable + * fun MyPreview(@PreviewParameter(PostPreviewParameterProvider::class, limit = 2) post: Post) { + * MyComposable(post) + * } + * ``` + * + * In this simple app we just return the hard-coded posts. When the app + * would be more complex - e.g. retrieving the posts from a server - this would + * be the right place to instantiate dummy instances. + */ +class PostPreviewParameterProvider : PreviewParameterProvider<Post> { + override val values = sequenceOf( + post1, post2, post3, post4, post5 + ) +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCards.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCards.kt index f71d0ed2d3..6956bc6fcb 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCards.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCards.kt @@ -16,35 +16,42 @@ package com.example.jetnews.ui.home +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconToggleButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers +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.clip -import androidx.compose.ui.res.imageResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.jetnews.R import com.example.jetnews.data.posts.impl.post3 import com.example.jetnews.model.Post -import com.example.jetnews.ui.Screen -import com.example.jetnews.ui.ThemedPreview +import com.example.jetnews.ui.theme.JetnewsTheme +import com.example.jetnews.ui.utils.BookmarkButton @Composable fun AuthorAndReadTime( @@ -52,112 +59,151 @@ fun AuthorAndReadTime( modifier: Modifier = Modifier ) { Row(modifier) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - val textStyle = MaterialTheme.typography.body2 - Text( - text = post.metadata.author.name, - style = textStyle - ) - Text( - text = " - ${post.metadata.readTimeMinutes} min read", - style = textStyle - ) - } + Text( + text = stringResource( + id = R.string.home_post_min_read, + formatArgs = arrayOf( + post.metadata.author.name, + post.metadata.readTimeMinutes + ) + ), + style = MaterialTheme.typography.bodyMedium + ) } } @Composable fun PostImage(post: Post, modifier: Modifier = Modifier) { - val image = post.imageThumb ?: imageResource(R.drawable.placeholder_1_1) - Image( - bitmap = image, + painter = painterResource(post.imageThumbId), + contentDescription = null, // decorative modifier = modifier - .preferredSize(40.dp, 40.dp) + .size(40.dp, 40.dp) .clip(MaterialTheme.shapes.small) ) } @Composable fun PostTitle(post: Post) { - Text(post.title, style = MaterialTheme.typography.subtitle1) + Text( + text = post.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) } @Composable fun PostCardSimple( post: Post, - navigateTo: (Screen) -> Unit, + navigateToArticle: (String) -> Unit, isFavorite: Boolean, onToggleFavorite: () -> Unit ) { + val bookmarkAction = stringResource(if (isFavorite) R.string.unbookmark else R.string.bookmark) Row( - modifier = Modifier.clickable(onClick = { navigateTo(Screen.Article(post.id)) }) - .padding(16.dp) + modifier = Modifier + .clickable(onClick = { navigateToArticle(post.id) }) + .semantics { + // By defining a custom action, we tell accessibility services that this whole + // composable has an action attached to it. The accessibility service can choose + // how to best communicate this action to the user. + customActions = listOf( + CustomAccessibilityAction( + label = bookmarkAction, + action = { onToggleFavorite(); true } + ) + ) + } ) { - PostImage(post, Modifier.padding(end = 16.dp)) - Column(modifier = Modifier.weight(1f)) { + PostImage(post, Modifier.padding(16.dp)) + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 10.dp) + ) { PostTitle(post) AuthorAndReadTime(post) } BookmarkButton( isBookmarked = isFavorite, - onClick = onToggleFavorite + onClick = onToggleFavorite, + // Remove button semantics so action can be handled at row level + modifier = Modifier + .clearAndSetSemantics {} + .padding(vertical = 2.dp, horizontal = 6.dp) ) } } @Composable -fun PostCardHistory(post: Post, navigateTo: (Screen) -> Unit) { +fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) { + var openDialog by remember { mutableStateOf(false) } + Row( - Modifier.clickable(onClick = { navigateTo(Screen.Article(post.id)) }) - .padding(16.dp) + Modifier + .clickable(onClick = { navigateToArticle(post.id) }) ) { PostImage( post = post, - modifier = Modifier.padding(end = 16.dp) + modifier = Modifier.padding(16.dp) ) - Column(Modifier.weight(1f)) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = "BASED ON YOUR HISTORY", - style = MaterialTheme.typography.overline - ) - } + Column( + Modifier + .weight(1f) + .padding(vertical = 12.dp) + ) { + Text( + text = stringResource(id = R.string.home_post_based_on_history), + style = MaterialTheme.typography.labelMedium + ) PostTitle(post = post) AuthorAndReadTime( post = post, modifier = Modifier.padding(top = 4.dp) ) } - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Icon(Icons.Filled.MoreVert) + IconButton(onClick = { openDialog = true }) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.cd_more_actions) + ) } } -} - -@Composable -fun BookmarkButton( - isBookmarked: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - IconToggleButton( - checked = isBookmarked, - onCheckedChange = { onClick() }, - modifier = modifier - ) { - if (isBookmarked) { - Icon(imageVector = Icons.Filled.Bookmark) - } else { - Icon(imageVector = Icons.Filled.BookmarkBorder) - } + if (openDialog) { + AlertDialog( + modifier = Modifier.padding(20.dp), + onDismissRequest = { openDialog = false }, + title = { + Text( + text = stringResource(id = R.string.fewer_stories), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = stringResource(id = R.string.fewer_stories_content), + style = MaterialTheme.typography.bodyLarge + ) + }, + confirmButton = { + Text( + text = stringResource(id = R.string.agree), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(15.dp) + .clickable { openDialog = false } + ) + } + ) } } @Preview("Bookmark Button") @Composable fun BookmarkButtonPreview() { - ThemedPreview { + JetnewsTheme { Surface { BookmarkButton(isBookmarked = false, onClick = { }) } @@ -167,7 +213,7 @@ fun BookmarkButtonPreview() { @Preview("Bookmark Button Bookmarked") @Composable fun BookmarkButtonBookmarkedPreview() { - ThemedPreview { + JetnewsTheme { Surface { BookmarkButton(isBookmarked = true, onClick = { }) } @@ -175,25 +221,22 @@ fun BookmarkButtonBookmarkedPreview() { } @Preview("Simple post card") +@Preview("Simple post card (dark)", uiMode = UI_MODE_NIGHT_YES) @Composable fun SimplePostPreview() { - ThemedPreview { - PostCardSimple(post3, {}, false, {}) + JetnewsTheme { + Surface { + PostCardSimple(post3, {}, false, {}) + } } } @Preview("Post History card") @Composable fun HistoryPostPreview() { - ThemedPreview { - PostCardHistory(post3, {}) - } -} - -@Preview("Simple post card dark theme") -@Composable -fun SimplePostDarkPreview() { - ThemedPreview(darkTheme = true) { - PostCardSimple(post3, {}, false, {}) + JetnewsTheme { + Surface { + PostCardHistory(post3, {}) + } } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsRoute.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsRoute.kt new file mode 100644 index 0000000000..6766bd2ef4 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsRoute.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetnews.ui.interests + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable + +/** + * Stateful composable that displays the Navigation route for the Interests screen. + * + * @param interestsViewModel ViewModel that handles the business logic of this screen + * @param isExpandedScreen (state) true if the screen is expanded + * @param openDrawer (event) request opening the app drawer + * @param snackbarHostState (state) state for screen snackbar host + */ +@Composable +fun InterestsRoute( + interestsViewModel: InterestsViewModel, + isExpandedScreen: Boolean, + openDrawer: () -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } +) { + val tabContent = rememberTabContent(interestsViewModel) + val (currentSection, updateSection) = rememberSaveable { + mutableStateOf(tabContent.first().section) + } + + InterestsScreen( + tabContent = tabContent, + currentSection = currentSection, + isExpandedScreen = isExpandedScreen, + onTabChange = updateSection, + openDrawer = openDrawer, + snackbarHostState = snackbarHostState + ) +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt index d1a4c64c28..521034b9f8 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,58 +16,74 @@ package com.example.jetnews.ui.interests +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.widget.Toast +import androidx.annotation.StringRes import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollableColumn 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.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredSize +import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Divider -import androidx.compose.material.DrawerValue -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.ScaffoldState -import androidx.compose.material.Tab -import androidx.compose.material.TabRow -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.rememberDrawerState -import androidx.compose.material.rememberScaffoldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.savedinstancestate.savedInstanceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.imageResource -import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.jetnews.R import com.example.jetnews.data.Result -import com.example.jetnews.data.interests.InterestsRepository +import com.example.jetnews.data.interests.InterestSection import com.example.jetnews.data.interests.TopicSelection -import com.example.jetnews.data.interests.TopicsMap import com.example.jetnews.data.interests.impl.FakeInterestsRepository -import com.example.jetnews.ui.AppDrawer -import com.example.jetnews.ui.Screen -import com.example.jetnews.ui.ThemedPreview -import com.example.jetnews.utils.produceUiState -import kotlinx.coroutines.launch +import com.example.jetnews.ui.theme.JetnewsTheme +import kotlin.math.max import kotlinx.coroutines.runBlocking -enum class Sections(val title: String) { - Topics("Topics"), - People("People"), - Publications("Publications") +enum class Sections(@StringRes val titleResId: Int) { + Topics(R.string.interests_section_topics), + People(R.string.interests_section_people), + Publications(R.string.interests_section_publications) } /** @@ -83,142 +99,143 @@ enum class Sections(val title: String) { class TabContent(val section: Sections, val content: @Composable () -> Unit) /** - * Stateful InterestsScreen manages state using [produceUiState] + * Stateless interest screen displays the tabs specified in [tabContent] adapting the UI to + * different screen sizes. * - * @param navigateTo (event) request navigation to [Screen] - * @param scaffoldState (state) state for screen Scaffold - * @param interestsRepository data source for this screen + * @param tabContent (slot) the tabs and their content to display on this screen, must be a + * non-empty list, tabs are displayed in the order specified by this list + * @param currentSection (state) the current tab to display, must be in [tabContent] + * @param isExpandedScreen (state) true if the screen is expanded + * @param onTabChange (event) request a change in [currentSection] to another tab from [tabContent] + * @param openDrawer (event) request opening the app drawer + * @param snackbarHostState (state) the state for the screen's [Scaffold] */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun InterestsScreen( - navigateTo: (Screen) -> Unit, - interestsRepository: InterestsRepository, - scaffoldState: ScaffoldState = rememberScaffoldState() + tabContent: List<TabContent>, + currentSection: Sections, + isExpandedScreen: Boolean, + onTabChange: (Sections) -> Unit, + openDrawer: () -> Unit, + snackbarHostState: SnackbarHostState ) { - // Returns a [CoroutineScope] that is scoped to the lifecycle of [InterestsScreen]. When this - // screen is removed from composition, the scope will be cancelled. - val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.cd_interests), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + }, + navigationIcon = { + if (!isExpandedScreen) { + IconButton(onClick = openDrawer) { + Icon( + painter = painterResource(R.drawable.ic_jetnews_logo), + contentDescription = stringResource( + R.string.cd_open_navigation_drawer + ), + ) + } + } + }, + actions = { + IconButton( + onClick = { + Toast.makeText( + context, + "Search is not yet implemented in this configuration", + Toast.LENGTH_LONG + ).show() + } + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.cd_search) + ) + } + } + ) + } + ) { innerPadding -> + val screenModifier = Modifier.padding(innerPadding) + InterestScreenContent( + currentSection, isExpandedScreen, + onTabChange, tabContent, screenModifier + ) + } +} + +/** + * Remembers the content for each tab on the Interests screen + * gathering application data from [InterestsViewModel] + */ +@Composable +fun rememberTabContent(interestsViewModel: InterestsViewModel): List<TabContent> { + // UiState of the InterestsScreen + val uiState by interestsViewModel.uiState.collectAsStateWithLifecycle() // Describe the screen sections here since each section needs 2 states and 1 event. // Pass them to the stateless InterestsScreen using a tabContent. val topicsSection = TabContent(Sections.Topics) { - val (topics) = produceUiState(interestsRepository) { - getTopics() - } - // collectAsState will read a [Flow] in Compose - val selectedTopics by interestsRepository.observeTopicsSelected().collectAsState(setOf()) - val onTopicSelect: (TopicSelection) -> Unit = { - coroutineScope.launch { interestsRepository.toggleTopicSelection(it) } - } - val data = topics.value.data ?: return@TabContent - TopicList(data, selectedTopics, onTopicSelect) + val selectedTopics by interestsViewModel.selectedTopics.collectAsStateWithLifecycle() + TabWithSections( + sections = uiState.topics, + selectedTopics = selectedTopics, + onTopicSelect = { interestsViewModel.toggleTopicSelection(it) } + ) } val peopleSection = TabContent(Sections.People) { - val (people) = produceUiState(interestsRepository) { - getPeople() - } - val selectedPeople by interestsRepository.observePeopleSelected().collectAsState(setOf()) - val onPeopleSelect: (String) -> Unit = { - coroutineScope.launch { interestsRepository.togglePersonSelected(it) } - } - val data = people.value.data ?: return@TabContent - PeopleList(data, selectedPeople, onPeopleSelect) + val selectedPeople by interestsViewModel.selectedPeople.collectAsStateWithLifecycle() + TabWithTopics( + topics = uiState.people, + selectedTopics = selectedPeople, + onTopicSelect = { interestsViewModel.togglePersonSelected(it) } + ) } val publicationSection = TabContent(Sections.Publications) { - val (publications) = produceUiState(interestsRepository) { - getPublications() - } - val selectedPublications by interestsRepository.observePublicationSelected().collectAsState(setOf()) - val onPublicationSelect: (String) -> Unit = { - coroutineScope.launch { interestsRepository.togglePublicationSelected(it) } - } - val data = publications.value.data ?: return@TabContent - PublicationList(data, selectedPublications, onPublicationSelect) + val selectedPublications by interestsViewModel.selectedPublications + .collectAsStateWithLifecycle() + TabWithTopics( + topics = uiState.publications, + selectedTopics = selectedPublications, + onTopicSelect = { interestsViewModel.togglePublicationSelected(it) } + ) } - val tabContent = listOf(topicsSection, peopleSection, publicationSection) - val (currentSection, updateSection) = savedInstanceState { tabContent.first().section } - InterestsScreen( - tabContent = tabContent, - tab = currentSection, - onTabChange = updateSection, - navigateTo = navigateTo, - scaffoldState = scaffoldState - ) -} - -/** - * Stateless interest screen displays the tabs specified in [tabContent] - * - * @param tabContent (slot) the tabs and their content to display on this screen, must be a non-empty - * list, tabs are displayed in the order specified by this list - * @param tab (state) the current tab to display, must be in [tabContent] - * @param onTabChange (event) request a change in [tab] to another tab from [tabContent] - * @param navigateTo (event) request navigation to [Screen] - * @param scaffoldState (state) the state for the screen's [Scaffold] - */ -@Composable -fun InterestsScreen( - tabContent: List<TabContent>, - tab: Sections, - onTabChange: (Sections) -> Unit, - navigateTo: (Screen) -> Unit, - scaffoldState: ScaffoldState, -) { - Scaffold( - scaffoldState = scaffoldState, - drawerContent = { - AppDrawer( - currentScreen = Screen.Interests, - closeDrawer = { scaffoldState.drawerState.close() }, - navigateTo = navigateTo - ) - }, - topBar = { - TopAppBar( - title = { Text("Interests") }, - navigationIcon = { - IconButton(onClick = { scaffoldState.drawerState.open() }) { - Icon(vectorResource(R.drawable.ic_jetnews_logo)) - } - } - ) - }, - bodyContent = { - TabContent(tab, onTabChange, tabContent) - } - ) + return listOf(topicsSection, peopleSection, publicationSection) } /** * Displays a tab row with [currentSection] selected and the body of the corresponding [tabContent]. * * @param currentSection (state) the tab that is currently selected + * @param isExpandedScreen (state) whether or not the screen is expanded * @param updateSection (event) request a change in tab selection * @param tabContent (slot) tabs and their content to display, must be a non-empty list, tabs are * displayed in the order of this list */ @Composable -private fun TabContent( +private fun InterestScreenContent( currentSection: Sections, + isExpandedScreen: Boolean, updateSection: (Sections) -> Unit, - tabContent: List<TabContent> + tabContent: List<TabContent>, + modifier: Modifier = Modifier ) { val selectedTabIndex = tabContent.indexOfFirst { it.section == currentSection } - Column { - TabRow( - selectedTabIndex = selectedTabIndex - ) { - tabContent.forEachIndexed { index, tabContent -> - Tab( - text = { Text(tabContent.section.title) }, - selected = selectedTabIndex == index, - onClick = { updateSection(tabContent.section) } - ) - } - } + Column(modifier) { + InterestsTabRow(selectedTabIndex, updateSection, tabContent, isExpandedScreen) + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + ) Box(modifier = Modifier.weight(1f)) { // display the current tab content which is a @Composable () -> Unit tabContent[selectedTabIndex].content() @@ -227,52 +244,11 @@ private fun TabContent( } /** - * Display the list for the topic tab - * - * @param topics (state) topics to display, mapped by section - * @param selectedTopics (state) currently selected topics - * @param onTopicSelect (event) request a topic selection be changed - */ -@Composable -private fun TopicList( - topics: TopicsMap, - selectedTopics: Set<TopicSelection>, - onTopicSelect: (TopicSelection) -> Unit -) { - TabWithSections(topics, selectedTopics, onTopicSelect) -} - -/** - * Display the list for people tab - * - * @param people (state) people to display - * @param selectedPeople (state) currently selected people - * @param onPersonSelect (event) request a person selection be changed - */ -@Composable -private fun PeopleList( - people: List<String>, - selectedPeople: Set<String>, - onPersonSelect: (String) -> Unit -) { - TabWithTopics(people, selectedPeople, onPersonSelect) -} - -/** - * Display a list for publications tab - * - * @param publications (state) publications to display - * @param selectedPublications (state) currently selected publications - * @param onPublicationSelect (event) request a publication selection be changed + * Modifier for UI containers that show interests items */ -@Composable -private fun PublicationList( - publications: List<String>, - selectedPublications: Set<String>, - onPublicationSelect: (String) -> Unit -) { - TabWithTopics(publications, selectedPublications, onPublicationSelect) -} +private val tabContainerModifier = Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally) /** * Display a simple list of topics @@ -287,13 +263,16 @@ private fun TabWithTopics( selectedTopics: Set<String>, onTopicSelect: (String) -> Unit ) { - ScrollableColumn(modifier = Modifier.padding(top = 16.dp)) { + InterestsAdaptiveContentLayout( + topPadding = 16.dp, + modifier = tabContainerModifier.verticalScroll(rememberScrollState()) + ) { topics.forEach { topic -> TopicItem( - topic, - selected = selectedTopics.contains(topic) - ) { onTopicSelect(topic) } - TopicDivider() + itemTitle = topic, + selected = selectedTopics.contains(topic), + onToggle = { onTopicSelect(topic) }, + ) } } } @@ -307,23 +286,27 @@ private fun TabWithTopics( */ @Composable private fun TabWithSections( - sections: TopicsMap, + sections: List<InterestSection>, selectedTopics: Set<TopicSelection>, onTopicSelect: (TopicSelection) -> Unit ) { - ScrollableColumn { + Column(tabContainerModifier.verticalScroll(rememberScrollState())) { sections.forEach { (section, topics) -> Text( text = section, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.subtitle1 + modifier = Modifier + .padding(16.dp) + .semantics { heading() }, + style = MaterialTheme.typography.titleMedium ) - topics.forEach { topic -> - TopicItem( - itemTitle = topic, - selected = selectedTopics.contains(TopicSelection(section, topic)) - ) { onTopicSelect(TopicSelection(section, topic)) } - TopicDivider() + InterestsAdaptiveContentLayout { + topics.forEach { topic -> + TopicItem( + itemTitle = topic, + selected = selectedTopics.contains(TopicSelection(section, topic)), + onToggle = { onTopicSelect(TopicSelection(section, topic)) }, + ) + } } } } @@ -337,189 +320,295 @@ private fun TabWithSections( * @param onToggle (event) toggle selection for topic */ @Composable -private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) { - val image = imageResource(R.drawable.placeholder_1_1) - Row( - modifier = Modifier - .toggleable( +private fun TopicItem( + itemTitle: String, + selected: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier +) { + Column(Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = modifier.toggleable( value = selected, onValueChange = { onToggle() } + ), + verticalAlignment = Alignment.CenterVertically + ) { + val image = painterResource(R.drawable.placeholder_1_1) + Image( + painter = image, + contentDescription = null, // decorative + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(4.dp)) ) - .padding(horizontal = 16.dp) - ) { - Image( - image, - Modifier - .align(Alignment.CenterVertically) - .preferredSize(56.dp, 56.dp) - .clip(RoundedCornerShape(4.dp)) - ) - Text( - text = itemTitle, - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(16.dp), - style = MaterialTheme.typography.subtitle1 - ) - Spacer(Modifier.weight(1f)) - SelectTopicButton( - modifier = Modifier.align(Alignment.CenterVertically), - selected = selected + Text( + text = itemTitle, + modifier = Modifier + .padding(16.dp) + .weight(1f), // Break line if the title is too long + style = MaterialTheme.typography.titleMedium + ) + Spacer(Modifier.width(16.dp)) + SelectTopicButton(selected = selected) + } + HorizontalDivider( + modifier = modifier.padding(start = 72.dp, top = 8.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) ) } } /** - * Full-width divider for topics + * TabRow for the InterestsScreen */ @Composable -private fun TopicDivider() { - Divider( - modifier = Modifier.padding(start = 72.dp, top = 8.dp, bottom = 8.dp), - color = MaterialTheme.colors.surface.copy(alpha = 0.08f) - ) +private fun InterestsTabRow( + selectedTabIndex: Int, + updateSection: (Sections) -> Unit, + tabContent: List<TabContent>, + isExpandedScreen: Boolean +) { + when (isExpandedScreen) { + false -> { + TabRow( + selectedTabIndex = selectedTabIndex, + contentColor = MaterialTheme.colorScheme.primary + ) { + InterestsTabRowContent(selectedTabIndex, updateSection, tabContent) + } + } + true -> { + ScrollableTabRow( + selectedTabIndex = selectedTabIndex, + contentColor = MaterialTheme.colorScheme.primary, + edgePadding = 0.dp + ) { + InterestsTabRowContent( + selectedTabIndex = selectedTabIndex, + updateSection = updateSection, + tabContent = tabContent, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } + } + } } -@Preview("Interests screen") @Composable -fun PreviewInterestsScreen() { - ThemedPreview { - InterestsScreen( - navigateTo = {}, - interestsRepository = FakeInterestsRepository() - ) +private fun InterestsTabRowContent( + selectedTabIndex: Int, + updateSection: (Sections) -> Unit, + tabContent: List<TabContent>, + modifier: Modifier = Modifier +) { + tabContent.forEachIndexed { index, content -> + val colorText = if (selectedTabIndex == index) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + } + Tab( + selected = selectedTabIndex == index, + onClick = { updateSection(content.section) }, + modifier = Modifier.heightIn(min = 48.dp) + ) { + Text( + text = stringResource(id = content.section.titleResId), + color = colorText, + style = MaterialTheme.typography.titleMedium, + modifier = modifier.paddingFromBaseline(top = 20.dp) + ) + } } } -@Preview("Interests screen dark theme") +/** + * Custom layout for the Interests screen that places items on the screen given the available size. + * + * For example: Given a list of items (A, B, C, D, E) and a screen size that allows 2 columns, + * the items will be displayed on the screen as follows: + * A B + * C D + * E + */ @Composable -fun PreviewInterestsScreenDark() { - ThemedPreview(darkTheme = true) { - val scaffoldState = rememberScaffoldState( - drawerState = rememberDrawerState(DrawerValue.Open) - ) - InterestsScreen( - navigateTo = {}, - scaffoldState = scaffoldState, - interestsRepository = FakeInterestsRepository() - ) +private fun InterestsAdaptiveContentLayout( + modifier: Modifier = Modifier, + topPadding: Dp = 0.dp, + itemSpacing: Dp = 4.dp, + itemMaxWidth: Dp = 450.dp, + multipleColumnsBreakPoint: Dp = 600.dp, + content: @Composable () -> Unit, +) { + Layout(modifier = modifier, content = content) { measurables, outerConstraints -> + // Convert parameters to Px. Safe to do as `Layout` measure block runs in a `Density` scope + val multipleColumnsBreakPointPx = multipleColumnsBreakPoint.roundToPx() + val topPaddingPx = topPadding.roundToPx() + val itemSpacingPx = itemSpacing.roundToPx() + val itemMaxWidthPx = itemMaxWidth.roundToPx() + + // Number of columns to display on the screen. This is harcoded to 2 due to + // the design mocks, but this logic could change in the future. + val columns = if (outerConstraints.maxWidth < multipleColumnsBreakPointPx) 1 else 2 + // Max width for each item taking into account available space, spacing and `itemMaxWidth` + val itemWidth = if (columns == 1) { + outerConstraints.maxWidth + } else { + val maxWidthWithSpaces = outerConstraints.maxWidth - (columns - 1) * itemSpacingPx + (maxWidthWithSpaces / columns).coerceIn(0, itemMaxWidthPx) + } + val itemConstraints = outerConstraints.copy(maxWidth = itemWidth) + + // Keep track of the height of each row to calculate the layout's final size + val rowHeights = IntArray(measurables.size / columns + 1) + // Measure elements with their maximum width and keep track of the height + val placeables = measurables.mapIndexed { index, measureable -> + val placeable = measureable.measure(itemConstraints) + // Update the height for each row + val row = index.floorDiv(columns) + rowHeights[row] = max(rowHeights[row], placeable.height) + placeable + } + + // Calculate maxHeight of the Interests layout. Heights of the row + top padding + val layoutHeight = topPaddingPx + rowHeights.sum() + // Calculate maxWidth of the Interests layout + val layoutWidth = itemWidth * columns + (itemSpacingPx * (columns - 1)) + + // Lay out given the max width and height + layout( + width = outerConstraints.constrainWidth(layoutWidth), + height = outerConstraints.constrainHeight(layoutHeight) + ) { + // Track the y co-ord we have placed children up to + var yPosition = topPaddingPx + // Split placeables in lists that don't exceed the number of columns + // and place them taking into account their width and spacing + placeables.chunked(columns).forEachIndexed { rowIndex, row -> + var xPosition = 0 + row.forEach { placeable -> + placeable.placeRelative(x = xPosition, y = yPosition) + xPosition += placeable.width + itemSpacingPx + } + yPosition += rowHeights[rowIndex] + } + } } } -@Preview("Interests screen drawer open") +@Preview("Interests screen", "Interests") +@Preview("Interests screen (dark)", "Interests", uiMode = UI_MODE_NIGHT_YES) +@Preview("Interests screen (big font)", "Interests", fontScale = 1.5f) @Composable -private fun PreviewDrawerOpen() { - ThemedPreview { - val scaffoldState = rememberScaffoldState( - drawerState = rememberDrawerState(DrawerValue.Open) - ) +fun PreviewInterestsScreenDrawer() { + JetnewsTheme { + val tabContent = getFakeTabsContent() + val (currentSection, updateSection) = rememberSaveable { + mutableStateOf(tabContent.first().section) + } + InterestsScreen( - navigateTo = {}, - scaffoldState = scaffoldState, - interestsRepository = FakeInterestsRepository() + tabContent = tabContent, + currentSection = currentSection, + isExpandedScreen = false, + onTabChange = updateSection, + openDrawer = { }, + snackbarHostState = SnackbarHostState() ) } } -@Preview("Interests screen drawer open dark theme") +@Preview("Interests screen navrail", "Interests", device = Devices.PIXEL_C) +@Preview( + "Interests screen navrail (dark)", "Interests", + uiMode = UI_MODE_NIGHT_YES, device = Devices.PIXEL_C +) +@Preview( + "Interests screen navrail (big font)", "Interests", + fontScale = 1.5f, device = Devices.PIXEL_C +) @Composable -private fun PreviewDrawerOpenDark() { - ThemedPreview(darkTheme = true) { - val scaffoldState = rememberScaffoldState( - drawerState = rememberDrawerState(DrawerValue.Open) - ) +fun PreviewInterestsScreenNavRail() { + JetnewsTheme { + val tabContent = getFakeTabsContent() + val (currentSection, updateSection) = rememberSaveable { + mutableStateOf(tabContent.first().section) + } + InterestsScreen( - navigateTo = {}, - scaffoldState = scaffoldState, - interestsRepository = FakeInterestsRepository() + tabContent = tabContent, + currentSection = currentSection, + isExpandedScreen = true, + onTabChange = updateSection, + openDrawer = { }, + snackbarHostState = SnackbarHostState() ) } } -@Preview("Interests screen topics tab") +@Preview("Interests screen topics tab", "Topics") +@Preview("Interests screen topics tab (dark)", "Topics", uiMode = UI_MODE_NIGHT_YES) @Composable fun PreviewTopicsTab() { - ThemedPreview { - TopicList(loadFakeTopics(), setOf(), {}) - } -} - -@Preview("Interests screen topics tab dark theme") -@Composable -fun PreviewTopicsTabDark() { - ThemedPreview(darkTheme = true) { - TopicList(loadFakeTopics(), setOf(), {}) - } -} - -@Composable -private fun loadFakeTopics(): TopicsMap { val topics = runBlocking { - FakeInterestsRepository().getTopics() - } - return (topics as Result.Success).data -} - -@Preview("Interests screen people tab") -@Composable -fun PreviewPeopleTab() { - ThemedPreview { - PeopleList(loadFakePeople(), setOf(), { }) + (FakeInterestsRepository().getTopics() as Result.Success).data } -} - -@Preview("Interests screen people tab dark theme") -@Composable -fun PreviewPeopleTabDark() { - ThemedPreview(darkTheme = true) { - PeopleList(loadFakePeople(), setOf(), { }) + JetnewsTheme { + Surface { + TabWithSections(topics, setOf()) { } + } } } +@Preview("Interests screen people tab", "People") +@Preview("Interests screen people tab (dark)", "People", uiMode = UI_MODE_NIGHT_YES) @Composable -private fun loadFakePeople(): List<String> { +fun PreviewPeopleTab() { val people = runBlocking { - FakeInterestsRepository().getPeople() + (FakeInterestsRepository().getPeople() as Result.Success).data + } + JetnewsTheme { + Surface { + TabWithTopics(people, setOf()) { } + } } - return (people as Result.Success).data } -@Preview("Interests screen publications tab") +@Preview("Interests screen publications tab", "Publications") +@Preview("Interests screen publications tab (dark)", "Publications", uiMode = UI_MODE_NIGHT_YES) @Composable fun PreviewPublicationsTab() { - ThemedPreview { - PublicationList(loadFakePublications(), setOf(), { }) + val publications = runBlocking { + (FakeInterestsRepository().getPublications() as Result.Success).data } -} - -@Preview("Interests screen publications tab dark theme") -@Composable -fun PreviewPublicationsTabDark() { - ThemedPreview(darkTheme = true) { - PublicationList(loadFakePublications(), setOf(), { }) + JetnewsTheme { + Surface { + TabWithTopics(publications, setOf()) { } + } } } -@Composable -private fun loadFakePublications(): List<String> { - val publications = runBlocking { - FakeInterestsRepository().getPublications() +private fun getFakeTabsContent(): List<TabContent> { + val interestsRepository = FakeInterestsRepository() + val topicsSection = TabContent(Sections.Topics) { + TabWithSections( + runBlocking { (interestsRepository.getTopics() as Result.Success).data }, + emptySet() + ) { } } - return (publications as Result.Success).data -} - -@Preview("Interests screen tab with topics") -@Composable -fun PreviewTabWithTopics() { - ThemedPreview { - TabWithTopics(topics = listOf("Hello", "Compose"), selectedTopics = setOf()) {} + val peopleSection = TabContent(Sections.People) { + TabWithTopics( + runBlocking { (interestsRepository.getPeople() as Result.Success).data }, + emptySet() + ) { } } -} - -@Preview("Interests screen tab with topics dark theme") -@Composable -fun PreviewTabWithTopicsDark() { - ThemedPreview(darkTheme = true) { - TabWithTopics(topics = listOf("Hello", "Compose"), selectedTopics = setOf()) {} + val publicationSection = TabContent(Sections.Publications) { + TabWithTopics( + runBlocking { (interestsRepository.getPublications() as Result.Success).data }, + emptySet() + ) { } } + + return listOf(topicsSection, peopleSection, publicationSection) } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsViewModel.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsViewModel.kt new file mode 100644 index 0000000000..57af7de1ed --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsViewModel.kt @@ -0,0 +1,137 @@ +/* + * 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.jetnews.ui.interests + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.jetnews.data.interests.InterestSection +import com.example.jetnews.data.interests.InterestsRepository +import com.example.jetnews.data.interests.TopicSelection +import com.example.jetnews.data.successOr +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * UI state for the Interests screen + */ +data class InterestsUiState( + val topics: List<InterestSection> = emptyList(), + val people: List<String> = emptyList(), + val publications: List<String> = emptyList(), + val loading: Boolean = false, +) + +class InterestsViewModel( + private val interestsRepository: InterestsRepository +) : ViewModel() { + + // UI state exposed to the UI + private val _uiState = MutableStateFlow(InterestsUiState(loading = true)) + val uiState: StateFlow<InterestsUiState> = _uiState.asStateFlow() + + val selectedTopics = + interestsRepository.observeTopicsSelected().stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptySet() + ) + + val selectedPeople = + interestsRepository.observePeopleSelected().stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptySet() + ) + + val selectedPublications = + interestsRepository.observePublicationSelected().stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptySet() + ) + + init { + refreshAll() + } + + fun toggleTopicSelection(topic: TopicSelection) { + viewModelScope.launch { + interestsRepository.toggleTopicSelection(topic) + } + } + + fun togglePersonSelected(person: String) { + viewModelScope.launch { + interestsRepository.togglePersonSelected(person) + } + } + + fun togglePublicationSelected(publication: String) { + viewModelScope.launch { + interestsRepository.togglePublicationSelected(publication) + } + } + + /** + * Refresh topics, people, and publications + */ + private fun refreshAll() { + _uiState.update { it.copy(loading = true) } + + viewModelScope.launch { + // Trigger repository requests in parallel + val topicsDeferred = async { interestsRepository.getTopics() } + val peopleDeferred = async { interestsRepository.getPeople() } + val publicationsDeferred = async { interestsRepository.getPublications() } + + // Wait for all requests to finish + val topics = topicsDeferred.await().successOr(emptyList()) + val people = peopleDeferred.await().successOr(emptyList()) + val publications = publicationsDeferred.await().successOr(emptyList()) + + _uiState.update { + it.copy( + loading = false, + topics = topics, + people = people, + publications = publications + ) + } + } + } + + /** + * Factory for InterestsViewModel that takes PostsRepository as a dependency + */ + companion object { + fun provideFactory( + interestsRepository: InterestsRepository, + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun <T : ViewModel> create(modelClass: Class<T>): T { + return InterestsViewModel(interestsRepository) as T + } + } + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/SelectTopicButton.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/SelectTopicButton.kt index d9c44a1adf..154f895adb 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/SelectTopicButton.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/SelectTopicButton.kt @@ -16,20 +16,23 @@ package com.example.jetnews.ui.interests +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredSize +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.example.jetnews.ui.ThemedPreview +import com.example.jetnews.ui.theme.JetnewsTheme @Composable fun SelectTopicButton( @@ -37,65 +40,64 @@ fun SelectTopicButton( selected: Boolean = false ) { val icon = if (selected) Icons.Filled.Done else Icons.Filled.Add + val iconColor = if (selected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.primary + } + val borderColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + } val backgroundColor = if (selected) { - MaterialTheme.colors.primary + MaterialTheme.colorScheme.primary } else { - MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + MaterialTheme.colorScheme.onPrimary } Surface( color = backgroundColor, shape = CircleShape, - modifier = modifier.preferredSize(36.dp, 36.dp) + border = BorderStroke(1.dp, borderColor), + modifier = modifier.size(36.dp, 36.dp) ) { - Icon(icon) + Image( + imageVector = icon, + colorFilter = ColorFilter.tint(iconColor), + modifier = Modifier.padding(8.dp), + contentDescription = null // toggleable at higher level + ) } } @Preview("Off") +@Preview("Off (dark)", uiMode = UI_MODE_NIGHT_YES) @Composable fun SelectTopicButtonPreviewOff() { SelectTopicButtonPreviewTemplate( - darkTheme = false, selected = false ) } @Preview("On") +@Preview("On (dark)", uiMode = UI_MODE_NIGHT_YES) @Composable fun SelectTopicButtonPreviewOn() { SelectTopicButtonPreviewTemplate( - darkTheme = false, - selected = true - ) -} - -@Preview("Off - dark theme") -@Composable -fun SelectTopicButtonPreviewOffDark() { - SelectTopicButtonPreviewTemplate( - darkTheme = true, - selected = false - ) -} - -@Preview("On - dark theme") -@Composable -fun SelectTopicButtonPreviewOnDark() { - SelectTopicButtonPreviewTemplate( - darkTheme = true, selected = true ) } @Composable private fun SelectTopicButtonPreviewTemplate( - darkTheme: Boolean = false, selected: Boolean ) { - ThemedPreview(darkTheme) { - SelectTopicButton( - modifier = Modifier.padding(32.dp), - selected = selected - ) + JetnewsTheme { + Surface { + SelectTopicButton( + modifier = Modifier.padding(32.dp), + selected = selected + ) + } } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/modifiers/KeyEvents.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/modifiers/KeyEvents.kt new file mode 100644 index 0000000000..11f4ec573d --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/modifiers/KeyEvents.kt @@ -0,0 +1,38 @@ +/* + * 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.jetnews.ui.modifiers + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType.Companion.KeyUp +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type + +/** + * Intercepts a key event rather than passing it on to children + */ +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.interceptKey(key: Key, onKeyEvent: () -> Unit): Modifier { + return this.onPreviewKeyEvent { + if (it.key == key && it.type == KeyUp) { // fire onKeyEvent on KeyUp to prevent duplicates + onKeyEvent() + true + } else it.key == key // only pass the key event to children if it's not the chosen key + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/state/UiState.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/state/UiState.kt deleted file mode 100644 index 44ebb1c11a..0000000000 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/state/UiState.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetnews.ui.state - -import com.example.jetnews.data.Result - -/** - * Immutable data class that allows for loading, data, and exception to be managed independently. - * - * This is useful for screens that want to show the last successful result while loading or a later - * refresh has caused an error. - */ -data class UiState<T>( - val loading: Boolean = false, - val exception: Exception? = null, - val data: T? = null -) { - /** - * True if this contains an error - */ - val hasError: Boolean - get() = exception != null - - /** - * True if this represents a first load - */ - val initialLoad: Boolean - get() = data == null && loading && !hasError -} - -/** - * Copy a UiState<T> based on a Result<T>. - * - * Result.Success will set all fields - * Result.Error will reset loading and exception only - */ -fun <T> UiState<T>.copyWithResult(value: Result<T>): UiState<T> { - return when (value) { - is Result.Success -> copy(loading = false, exception = null, data = value.data) - is Result.Error -> copy(loading = false, exception = value.exception) - } -} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Color.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Color.kt index fbd78623f9..1fef147349 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Color.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Color.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * 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. @@ -18,8 +18,58 @@ package com.example.jetnews.ui.theme import androidx.compose.ui.graphics.Color -val Red200 = Color(0xfff297a2) -val Red300 = Color(0xffea6d7e) -val Red700 = Color(0xffdd0d3c) -val Red800 = Color(0xffd00036) -val Red900 = Color(0xffc20029) +val md_theme_light_primary = Color(0xFFBF0031) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFFFDAD9) +val md_theme_light_onPrimaryContainer = Color(0xFF40000A) +val md_theme_light_secondary = Color(0xFF775656) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFFFDAD9) +val md_theme_light_onSecondaryContainer = Color(0xFF2C1516) +val md_theme_light_tertiary = Color(0xFF755A2F) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFDDAF) +val md_theme_light_onTertiaryContainer = Color(0xFF281800) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFFFBFF) +val md_theme_light_onBackground = Color(0xFF201A1A) +val md_theme_light_surface = Color(0xFFFFFBFF) +val md_theme_light_onSurface = Color(0xFF201A1A) +val md_theme_light_surfaceVariant = Color(0xFFF4DDDD) +val md_theme_light_onSurfaceVariant = Color(0xFF524343) +val md_theme_light_outline = Color(0xFF857373) +val md_theme_light_inverseOnSurface = Color(0xFFFBEEED) +val md_theme_light_inverseSurface = Color(0xFF362F2F) +val md_theme_light_inversePrimary = Color(0xFFFFB3B4) +val md_theme_light_surfaceTint = Color(0xFFBF0031) + +val md_theme_dark_primary = Color(0xFFFFB3B4) +val md_theme_dark_onPrimary = Color(0xFF680016) +val md_theme_dark_primaryContainer = Color(0xFF920023) +val md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD9) +val md_theme_dark_secondary = Color(0xFFE6BDBC) +val md_theme_dark_onSecondary = Color(0xFF44292A) +val md_theme_dark_secondaryContainer = Color(0xFF5D3F3F) +val md_theme_dark_onSecondaryContainer = Color(0xFFFFDAD9) +val md_theme_dark_tertiary = Color(0xFFE5C18D) +val md_theme_dark_onTertiary = Color(0xFF422C05) +val md_theme_dark_tertiaryContainer = Color(0xFF5B421A) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFDDAF) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF201A1A) +val md_theme_dark_onBackground = Color(0xFFECE0DF) +val md_theme_dark_surface = Color(0xFF201A1A) +val md_theme_dark_onSurface = Color(0xFFECE0DF) +val md_theme_dark_surfaceVariant = Color(0xFF524343) +val md_theme_dark_onSurfaceVariant = Color(0xFFD7C1C1) +val md_theme_dark_outline = Color(0xFFA08C8C) +val md_theme_dark_inverseOnSurface = Color(0xFF201A1A) +val md_theme_dark_inverseSurface = Color(0xFFECE0DF) +val md_theme_dark_inversePrimary = Color(0xFFBF0031) +val md_theme_dark_surfaceTint = Color(0xFFFFB3B4) diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Shape.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Shape.kt index b5fae23ab9..db5e33b888 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Shape.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Shape.kt @@ -17,7 +17,7 @@ package com.example.jetnews.ui.theme import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes +import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp val JetnewsShapes = Shapes( diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Theme.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Theme.kt index 0cf964ffb9..20b4c960e1 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Theme.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Theme.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * 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. @@ -16,30 +16,74 @@ package com.example.jetnews.ui.theme +import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.MaterialTheme -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext -private val LightThemeColors = lightColors( - primary = Red700, - primaryVariant = Red900, - onPrimary = Color.White, - secondary = Red700, - secondaryVariant = Red900, - onSecondary = Color.White, - error = Red800 +val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, ) -private val DarkThemeColors = darkColors( - primary = Red300, - primaryVariant = Red700, - onPrimary = Color.Black, - secondary = Red300, - onSecondary = Color.White, - error = Red200 +val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, ) @Composable @@ -47,10 +91,18 @@ fun JetnewsTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { + val colorScheme = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } else { + if (darkTheme) DarkColors else LightColors + } + MaterialTheme( - colors = if (darkTheme) DarkThemeColors else LightThemeColors, - typography = JetnewsTypography, + colorScheme = colorScheme, shapes = JetnewsShapes, + typography = JetnewsTypography, content = content ) } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Type.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Type.kt index 2191d27ccc..71bb56ba6c 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Type.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/theme/Type.kt @@ -16,75 +16,95 @@ package com.example.jetnews.ui.theme -import androidx.compose.material.Typography +import androidx.compose.material3.Typography +import androidx.compose.ui.text.PlatformTextStyle 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.font.font -import androidx.compose.ui.text.font.fontFamily +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.sp import com.example.jetnews.R -private val Montserrat = fontFamily( - font(R.font.montserrat_regular), - font(R.font.montserrat_medium, FontWeight.W500), - font(R.font.montserrat_semibold, FontWeight.W600) +private val Montserrat = FontFamily( + Font(R.font.montserrat_regular), + Font(R.font.montserrat_medium, FontWeight.W500) ) -private val Domine = fontFamily( - fonts = listOf( - font(R.font.domine_regular), - font(R.font.domine_bold, FontWeight.Bold) +@Suppress("DEPRECATION") +val defaultTextStyle = TextStyle( + fontFamily = Montserrat, + platformStyle = PlatformTextStyle( + includeFontPadding = false + ), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None ) ) val JetnewsTypography = Typography( - h4 = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W600, - fontSize = 30.sp - ), - h5 = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W600, - fontSize = 24.sp - ), - h6 = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W600, - fontSize = 20.sp - ), - subtitle1 = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W600, - fontSize = 16.sp - ), - subtitle2 = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W500, - fontSize = 14.sp - ), - body1 = TextStyle( - fontFamily = Domine, - fontWeight = FontWeight.Normal, - fontSize = 16.sp - ), - body2 = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp - ), - button = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W500, - fontSize = 14.sp - ), - caption = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.Normal, - fontSize = 12.sp - ), - overline = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W500, - fontSize = 12.sp - ) + displayLarge = defaultTextStyle.copy( + fontSize = 57.sp, lineHeight = 64.sp, letterSpacing = (-0.25).sp + ), + displayMedium = defaultTextStyle.copy( + fontSize = 45.sp, lineHeight = 52.sp, letterSpacing = 0.sp + ), + displaySmall = defaultTextStyle.copy( + fontSize = 36.sp, lineHeight = 44.sp, letterSpacing = 0.sp + ), + headlineLarge = defaultTextStyle.copy( + fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.sp, lineBreak = LineBreak.Heading + ), + headlineMedium = defaultTextStyle.copy( + fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.sp, lineBreak = LineBreak.Heading + ), + headlineSmall = defaultTextStyle.copy( + fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.sp, lineBreak = LineBreak.Heading + ), + titleLarge = defaultTextStyle.copy( + fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp, lineBreak = LineBreak.Heading + ), + titleMedium = defaultTextStyle.copy( + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + fontWeight = FontWeight.Medium, + lineBreak = LineBreak.Heading + ), + titleSmall = defaultTextStyle.copy( + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + fontWeight = FontWeight.Medium, + lineBreak = LineBreak.Heading + ), + labelLarge = defaultTextStyle.copy( + fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, fontWeight = FontWeight.Medium + ), + labelMedium = defaultTextStyle.copy( + fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, fontWeight = FontWeight.Medium + ), + labelSmall = defaultTextStyle.copy( + fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, fontWeight = FontWeight.Medium + ), + bodyLarge = defaultTextStyle.copy( + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + lineBreak = LineBreak.Paragraph + ), + bodyMedium = defaultTextStyle.copy( + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + lineBreak = LineBreak.Paragraph + ), + bodySmall = defaultTextStyle.copy( + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + lineBreak = LineBreak.Paragraph + ), ) diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/utils/JetnewsIcons.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/utils/JetnewsIcons.kt new file mode 100644 index 0000000000..ce0c69b0ae --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/utils/JetnewsIcons.kt @@ -0,0 +1,88 @@ +/* + * 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.jetnews.ui.utils + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.ThumbUpOffAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import com.example.jetnews.R + +@Composable +fun FavoriteButton(onClick: () -> Unit) { + IconButton(onClick) { + Icon( + imageVector = Icons.Filled.ThumbUpOffAlt, + contentDescription = stringResource(R.string.cd_add_to_favorites) + ) + } +} + +@Composable +fun BookmarkButton( + isBookmarked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val clickLabel = stringResource( + if (isBookmarked) R.string.unbookmark else R.string.bookmark + ) + IconToggleButton( + checked = isBookmarked, + onCheckedChange = { onClick() }, + modifier = modifier.semantics { + // Use a custom click label that accessibility services can communicate to the user. + // We only want to override the label, not the actual action, so for the action we pass null. + this.onClick(label = clickLabel, action = null) + } + ) { + Icon( + imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Filled.BookmarkBorder, + contentDescription = null // handled by click label of parent + ) + } +} + +@Composable +fun ShareButton(onClick: () -> Unit) { + IconButton(onClick) { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = stringResource(R.string.cd_share) + ) + } +} + +@Composable +fun TextSettingsButton(onClick: () -> Unit) { + IconButton(onClick) { + Icon( + painter = painterResource(R.drawable.ic_text_settings), + contentDescription = stringResource(R.string.cd_text_settings) + ) + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/utils/ErrorMessage.kt b/JetNews/app/src/main/java/com/example/jetnews/utils/ErrorMessage.kt new file mode 100644 index 0000000000..d05a2fdaaa --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/utils/ErrorMessage.kt @@ -0,0 +1,21 @@ +/* + * 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.jetnews.utils + +import androidx.annotation.StringRes + +data class ErrorMessage(val id: Long, @StringRes val messageId: Int) diff --git a/JetNews/app/src/main/java/com/example/jetnews/utils/LazyListUtils.kt b/JetNews/app/src/main/java/com/example/jetnews/utils/LazyListUtils.kt new file mode 100644 index 0000000000..064b2e0fd0 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/utils/LazyListUtils.kt @@ -0,0 +1,23 @@ +/* + * 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.jetnews.utils + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.derivedStateOf + +val LazyListState.isScrolled: Boolean + get() = derivedStateOf { firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0 }.value diff --git a/JetNews/app/src/main/java/com/example/jetnews/utils/MapExtensions.kt b/JetNews/app/src/main/java/com/example/jetnews/utils/MapExtensions.kt index e76464766e..2b05e67539 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/utils/MapExtensions.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/utils/MapExtensions.kt @@ -16,8 +16,10 @@ package com.example.jetnews.utils -internal fun <E> MutableSet<E>.addOrRemove(element: E) { - if (!add(element)) { - remove(element) - } +internal fun <E> Set<E>.addOrRemove(element: E): Set<E> { + return this.toMutableSet().apply { + if (!add(element)) { + remove(element) + } + }.toSet() } diff --git a/JetNews/app/src/main/java/com/example/jetnews/utils/MultipreviewAnnotations.kt b/JetNews/app/src/main/java/com/example/jetnews/utils/MultipreviewAnnotations.kt new file mode 100644 index 0000000000..bb2d75912d --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/utils/MultipreviewAnnotations.kt @@ -0,0 +1,82 @@ +/* + * 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.jetnews.utils + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Preview + +/** + * Add this multipreview annotation to a composable to render the composable in extra small and + * extra large font size. + * + * Read more in the [documentation](https://d.android.com/jetpack/compose/tooling#preview-multipreview) + */ +@Preview( + name = "small font", + group = "font scales", + fontScale = 0.5f +) +@Preview( + name = "large font", + group = "font scales", + fontScale = 1.5f +) +annotation class FontScalePreviews + +/** + * Add this multipreview annotation to a composable to render the composable on various device + * sizes: phone, foldable, and tablet. + * + * Read more in the [documentation](https://d.android.com/jetpack/compose/tooling#preview-multipreview) + */ +@Preview( + name = "phone", + group = "devices", + device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480" +) +@Preview( + name = "foldable", + group = "devices", + device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480" +) +@Preview( + name = "tablet", + group = "devices", + device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480" +) +annotation class DevicePreviews + +/** + * Add this multipreview annotation to a composable to render the composable in various common + * configurations: + * - Dark theme + * - Small and large font size + * - various device sizes + * + * Read more in the [documentation](https://d.android.com/jetpack/compose/tooling#preview-multipreview) + * + * _Note: Combining multipreview annotations doesn't mean all the different combinations are shown. + * Instead, each multipreview annotation acts by its own and renders only its own variants._ + */ +@Preview( + name = "dark theme", + group = "themes", + uiMode = UI_MODE_NIGHT_YES +) +@FontScalePreviews +@DevicePreviews +annotation class CompletePreviews diff --git a/JetNews/app/src/main/java/com/example/jetnews/utils/ProduceUiState.kt b/JetNews/app/src/main/java/com/example/jetnews/utils/ProduceUiState.kt deleted file mode 100644 index 94d6ac1578..0000000000 --- a/JetNews/app/src/main/java/com/example/jetnews/utils/ProduceUiState.kt +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetnews.utils - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import com.example.jetnews.data.Result -import com.example.jetnews.ui.state.UiState -import com.example.jetnews.ui.state.copyWithResult -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch - -/** - * Result object for [produceUiState]. - * - * It is intended that you destructure this class at the call site. Here is an example usage that - * calls dataSource.loadData() and then displays a UI based on the result. - * - * ``` - * val (result, onRefresh, onClearError) = produceUiState(dataSource) { loadData() } - * Text(result.value) - * Button(onClick = onRefresh) { Text("Refresh" } - * Button(onClick = onClearError) { Text("Clear loading error") } - * ``` - * - * @param result (state) the current result of this producer in a state object - * @param onRefresh (event) triggers a refresh of this producer - * @param onClearError (event) clear any error values returned by this producer, useful for - * transient error displays. - */ -data class ProducerResult<T>( - val result: State<T>, - val onRefresh: () -> Unit, - val onClearError: () -> Unit -) - -/** - * Launch a coroutine to create refreshable [UiState] from a suspending producer. - * - * [Producer] is any object that has a suspending method that returns [Result]. In the [block] call - * the suspending method that produces a single value. The result of this call will be returned - * along with an event to refresh (or call [block] again), and another event to clear error results. - * - * It is intended that you destructure the return at the call site. Here is an example usage that - * calls dataSource.loadData() and then displays a UI based on the result. - * - * ``` - * val (result, onRefresh, onClearError) = produceUiState(dataSource) { loadData() } - * Text(result.value) - * Button(onClick = onRefresh) { Text("Refresh" } - * Button(onClick = onClearError) { Text("Clear loading error") } - * ``` - * - * Repeated calls to onRefresh are conflated while a request is in progress. - * - * @param producer the data source to load data from - * @param block suspending lambda that produces a single value from the data source - * @return data state, onRefresh event, and onClearError event - */ -@Composable -fun <Producer, T> produceUiState( - producer: Producer, - block: suspend Producer.() -> Result<T> -): ProducerResult<UiState<T>> = produceUiState(producer, Unit, block) - -/** - * Launch a coroutine to create refreshable [UiState] from a suspending producer. - * - * [Producer] is any object that has a suspending method that returns [Result]. In the [block] call - * the suspending method that produces a single value. The result of this call will be returned - * along with an event to refresh (or call [block] again), and another event to clear error results. - * - * It is intended that you destructure the return at the call site. Here is an example usage that - * calls dataSource.loadData(resourceId) and then displays a UI based on the result. - * - * ``` - * val (result, onRefresh, onClearError) = produceUiState(dataSource, resourceId) { - * loadData(resourceId) - * } - * Text(result.value) - * Button(onClick = onRefresh) { Text("Refresh" } - * Button(onClick = onClearError) { Text("Clear loading error") } - * ``` - * - * Repeated calls to onRefresh are conflated while a request is in progress. - * - * @param producer the data source to load data from - * @param key any argument used by production lambda, such as a resource ID - * @param block suspending lambda that produces a single value from the data source - * @return data state, onRefresh event, and onClearError event - */ -@OptIn(ExperimentalCoroutinesApi::class) -@Composable -fun <Producer, T> produceUiState( - producer: Producer, - key: Any?, - block: suspend Producer.() -> Result<T> -): ProducerResult<UiState<T>> { - // posting to this channel will trigger a single refresh - val refreshChannel = remember { Channel<Unit>(Channel.CONFLATED) } - // posting to this channel will clear the current error condition (if any) - val errorClearChannel = remember { Channel<Unit>(Channel.CONFLATED) } - - val result = produceState(UiState<T>(loading = true), producer, key) { - // whenever the coroutine restarts from producer or key changes, clear the previous result - // immediately and force refresh - value = UiState(loading = true) - refreshChannel.send(Unit) - - // launch a new coroutine to handle errorClear events async - launch { - // This for-loop will loop until the [produceState] coroutine is cancelled. - for (clearEvent in errorClearChannel) { - // This for-loop will suspend when errorClearChanel is empty, and resume when the - // next value is offered or sent to the chanel. - value = value.copy(exception = null) - } - } - - // This for-loop will loop until the [produceState] coroutine is cancelled. - for (refreshEvent in refreshChannel) { - // whenever a refresh is triggered, call block again. This for-loop will suspend when - // refreshChannel is empty, and resume when the next value is offered or sent to the - // channel. - value = value.copy(loading = true) - value = value.copyWithResult(producer.block()) - } - } - return ProducerResult( - result = result, - onRefresh = { refreshChannel.offer(Unit) }, - onClearError = { errorClearChannel.offer(Unit) } - ) -} diff --git a/JetNews/app/src/main/java/com/example/jetnews/utils/SavedStateHandleUtils.kt b/JetNews/app/src/main/java/com/example/jetnews/utils/SavedStateHandleUtils.kt deleted file mode 100644 index 04435745c4..0000000000 --- a/JetNews/app/src/main/java/com/example/jetnews/utils/SavedStateHandleUtils.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetnews.utils - -import android.os.Bundle -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.SavedStateHandle - -/** - * Return a [MutableState] that will automatically be saved in a [SavedStateHandle]. - * - * This can be used from ViewModels to create a compose-observable value that survives rotation. It - * supports arbitrary types with manual conversion to a [Bundle]. - * - * @param save convert [T] to a [Bundle] for saving - * @param restore restore a [T] from a [Bundle] - */ -fun <T> SavedStateHandle.getMutableStateOf( - key: String, - default: T, - save: (T) -> Bundle, - restore: (Bundle) -> T -): MutableState<T> { - val bundle: Bundle? = get(key) - val initial = if (bundle == null) { default } else { restore(bundle) } - val state = mutableStateOf(initial) - setSavedStateProvider(key) { - save(state.value) - } - return state -} diff --git a/JetNews/app/src/main/res/drawable-nodpi/post_6.png b/JetNews/app/src/main/res/drawable-nodpi/post_6.png new file mode 100644 index 0000000000..3ad0c29522 Binary files /dev/null and b/JetNews/app/src/main/res/drawable-nodpi/post_6.png differ diff --git a/JetNews/app/src/main/res/drawable-nodpi/post_6_thumb.png b/JetNews/app/src/main/res/drawable-nodpi/post_6_thumb.png new file mode 100644 index 0000000000..092cc3f13b Binary files /dev/null and b/JetNews/app/src/main/res/drawable-nodpi/post_6_thumb.png differ diff --git a/JetNews/app/src/main/res/drawable/ic_jetnews_bookmark.xml b/JetNews/app/src/main/res/drawable/ic_jetnews_bookmark.xml new file mode 100644 index 0000000000..9ea9d8ef5b --- /dev/null +++ b/JetNews/app/src/main/res/drawable/ic_jetnews_bookmark.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. +--> +<vector android:height="24dp" android:tint="#000000" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M17,3L7,3c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3L19,5c0,-1.1 -0.9,-2 -2,-2zM17,18l-5,-2.18L7,18L7,5h10v13z"/> +</vector> diff --git a/JetNews/app/src/main/res/drawable/ic_jetnews_bookmark_filled.xml b/JetNews/app/src/main/res/drawable/ic_jetnews_bookmark_filled.xml new file mode 100644 index 0000000000..884c182735 --- /dev/null +++ b/JetNews/app/src/main/res/drawable/ic_jetnews_bookmark_filled.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. +--> +<vector android:height="24dp" android:tint="#000000" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M17,3H7c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3V5c0,-1.1 -0.9,-2 -2,-2z"/> +</vector> diff --git a/JetNews/app/src/main/res/drawable/icon_article_background.xml b/JetNews/app/src/main/res/drawable/icon_article_background.xml new file mode 100644 index 0000000000..5f54f0f048 --- /dev/null +++ b/JetNews/app/src/main/res/drawable/icon_article_background.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <group> + <path android:name="square" + android:fillColor="#FF073042" + android:pathData="M0,0 L24,0 L24,24 L0,24 z" /> + <path android:fillColor="#3DDC84" + android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"/> + + </group> +</vector> \ No newline at end of file diff --git a/JetNews/app/src/main/res/font/domine_bold.ttf b/JetNews/app/src/main/res/font/domine_bold.ttf deleted file mode 100755 index 329288c6bf..0000000000 Binary files a/JetNews/app/src/main/res/font/domine_bold.ttf and /dev/null differ diff --git a/JetNews/app/src/main/res/font/domine_regular.ttf b/JetNews/app/src/main/res/font/domine_regular.ttf deleted file mode 100755 index c387575c20..0000000000 Binary files a/JetNews/app/src/main/res/font/domine_regular.ttf and /dev/null differ diff --git a/JetNews/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/JetNews/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 1a1449ec8a..c93504688b 100644 --- a/JetNews/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/JetNews/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -15,4 +15,5 @@ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@drawable/ic_launcher_background" /> <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> </adaptive-icon> diff --git a/JetNews/app/src/main/res/values-night/colors.xml b/JetNews/app/src/main/res/values-night/colors.xml deleted file mode 100644 index 70a52f0088..0000000000 --- a/JetNews/app/src/main/res/values-night/colors.xml +++ /dev/null @@ -1,17 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<resources> - <color name="status_bar">#0e0e0e</color> -</resources> diff --git a/JetNews/app/src/main/res/values/colors.xml b/JetNews/app/src/main/res/values/colors.xml index e345edec0b..eef7bd07a5 100644 --- a/JetNews/app/src/main/res/values/colors.xml +++ b/JetNews/app/src/main/res/values/colors.xml @@ -13,7 +13,6 @@ the License. --> <resources> - <color name="red700">#dd0d3e</color> - <color name="red900">#c20029</color> - <color name="status_bar">@color/red900</color> + <!-- Status bar --> + <color name="black30">#4D000000</color> </resources> diff --git a/JetNews/app/src/main/res/values/integers.xml b/JetNews/app/src/main/res/values/integers.xml new file mode 100644 index 0000000000..922c325e17 --- /dev/null +++ b/JetNews/app/src/main/res/values/integers.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. +--> +<resources> + <!-- Refresh widget every 1 hour --> + <integer name="widget_update_period_millis">3600000</integer> +</resources> \ No newline at end of file diff --git a/JetNews/app/src/main/res/values/strings.xml b/JetNews/app/src/main/res/values/strings.xml index e89be4036d..cd9aa9b7cb 100644 --- a/JetNews/app/src/main/res/values/strings.xml +++ b/JetNews/app/src/main/res/values/strings.xml @@ -16,4 +16,44 @@ <string name="app_name">Jetnews</string> <string name="load_error">Can\'t update latest news</string> <string name="retry">Retry</string> + <string name="cd_add_to_favorites">Add to favorites</string> + <string name="cd_share">Share</string> + <string name="cd_text_settings">Text settings</string> + <string name="cd_open_navigation_drawer">Open navigation drawer</string> + <string name="cd_more_actions">More actions</string> + <string name="unbookmark">unbookmark</string> + <string name="bookmark">bookmark</string> + <string name="cd_search">Search</string> + <string name="cd_interests">Interests</string> + <string name="published_in">Published in: \n%1$s</string> + <string name="fewer_stories">Show fewer stories like this?</string> + <string name="fewer_stories_content">This feature is not yet implemented</string> + <string name="agree">Agree</string> + <string name="close">Close</string> + + <!-- Home Screen --> + <string name="home_title">Home</string> + <string name="home_tap_to_load_content">Tap to load content</string> + <string name="home_top_section_title">Top stories for you</string> + <string name="home_popular_section_title">Popular on Jetnews</string> + <string name="home_post_min_read">%1$s - %2$d min read</string> + <string name="home_post_based_on_history">BASED ON YOUR HISTORY</string> + <string name="home_search">Search articles</string> + + <!-- Article Screen --> + <string name="article_functionality_not_available">Functionality not available \uD83D\uDE48</string> + <string name="article_share_post">Share post</string> + <string name="article_post_min_read">%1$s • %2$d min read</string> + + <!-- Interests Screen --> + <string name="interests_title">Interests</string> + <string name="interests_section_topics">Topics</string> + <string name="interests_section_people">People</string> + <string name="interests_section_publications">Publications</string> + + <!-- Navigation actions --> + <string name="cd_navigate_up">Navigate up</string> + <string name="cd_navigate_home">Navigate to the home screen</string> + <string name="cd_navigate_interests">Navigate to the interests screen</string> + </resources> diff --git a/JetNews/app/src/main/res/values/styles.xml b/JetNews/app/src/main/res/values/styles.xml deleted file mode 100644 index 09af1a99ba..0000000000 --- a/JetNews/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,27 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<resources> - - <!-- Base application theme. In this Jetpack Compose sample app, these colors - don't affect the Composable functions, just the color of the Status Bar. - For the colors used by the Composable functions, see Theme.kt --> - <style name="Theme.Jetnews" parent="Theme.AppCompat.Light.NoActionBar"> - <item name="colorPrimary">@color/red700</item> - <item name="colorPrimaryDark">@color/red900</item> - <item name="colorAccent">@color/red700</item> - <item name="android:statusBarColor">@color/status_bar</item> - </style> - -</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + 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 + + 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. +--> +<resources> + <style name="Theme.Jetnews" parent="android:Theme.Material.NoActionBar"/> +</resources> diff --git a/JetNews/app/src/main/res/xml-v31/jetnews_glance_appwidget_info.xml b/JetNews/app/src/main/res/xml-v31/jetnews_glance_appwidget_info.xml new file mode 100644 index 0000000000..058aabd273 --- /dev/null +++ b/JetNews/app/src/main/res/xml-v31/jetnews_glance_appwidget_info.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. +--> +<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" + android:description="@string/app_name" + android:initialLayout="@layout/glance_default_loading_layout" + android:minHeight="200dp" + android:minWidth="150dp" + android:resizeMode="horizontal|vertical" + android:targetCellHeight="3" + android:targetCellWidth="3" + android:updatePeriodMillis="@integer/widget_update_period_millis" + android:widgetCategory="home_screen" /> \ No newline at end of file diff --git a/JetNews/app/src/main/res/xml/jetnews_glance_appwidget_info.xml b/JetNews/app/src/main/res/xml/jetnews_glance_appwidget_info.xml new file mode 100644 index 0000000000..7fe57799b1 --- /dev/null +++ b/JetNews/app/src/main/res/xml/jetnews_glance_appwidget_info.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. +--> +<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" + android:description="@string/app_name" + android:initialLayout="@layout/glance_default_loading_layout" + android:minHeight="200dp" + android:minWidth="150dp" + android:resizeMode="horizontal|vertical" + android:updatePeriodMillis="@integer/widget_update_period_millis" + android:widgetCategory="home_screen" /> \ No newline at end of file diff --git a/JetNews/build.gradle b/JetNews/build.gradle deleted file mode 100644 index 39246c6c94..0000000000 --- a/JetNews/build.gradle +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * 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. - */ - -buildscript { - ext.kotlin_version = '1.4.21' - ext.compose_version = '1.0.0-alpha10' - ext.coroutines_version = '1.4.2' - - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.0.0-alpha04' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -plugins { - id 'com.diffplug.spotless' version '5.7.0' -} - -subprojects { - repositories { - google() - jcenter() - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - - ktlint("0.39.0") - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } -} \ No newline at end of file diff --git a/JetNews/build.gradle.kts b/JetNews/build.gradle.kts new file mode 100644 index 0000000000..30355ffe44 --- /dev/null +++ b/JetNews/build.gradle.kts @@ -0,0 +1,73 @@ +/* + * 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 + alias(libs.plugins.spotless) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure<com.diffplug.gradle.spotless.SpotlessExtension> { + ratchetFrom = "origin/main" + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt") + ktlint().editorConfigOverride( + mapOf( + "ktlint_code_style" to "android_studio", + "ij_kotlin_allow_trailing_comma" to true, + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + // 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"), "(^(?![\\/ ]\\*).*$)") + } + kotlinGradle { + target("*.gradle.kts") + ktlint() + } + } +} diff --git a/JetNews/buildscripts/toml-updater-config.gradle b/JetNews/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/JetNews/buildscripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/JetNews/debug.keystore b/JetNews/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/JetNews/debug.keystore and /dev/null differ diff --git a/JetNews/debug_2.keystore b/JetNews/debug_2.keystore new file mode 100644 index 0000000000..b42c971788 Binary files /dev/null and b/JetNews/debug_2.keystore differ diff --git a/JetNews/gradle.properties b/JetNews/gradle.properties index b2d834ce9c..9299bc6d0f 100644 --- a/JetNews/gradle.properties +++ b/JetNews/gradle.properties @@ -37,6 +37,3 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/JetNews/gradle/libs.versions.toml b/JetNews/gradle/libs.versions.toml new file mode 100644 index 0000000000..29943df2e6 --- /dev/null +++ b/JetNews/gradle/libs.versions.toml @@ -0,0 +1,177 @@ +##### +# This file is duplicated to individual samples from the global scripts/libs.versions.toml +# Do not add a dependency to an individual sample, edit the global version instead. +##### +[versions] +accompanist = "0.37.2" +android-material3 = "1.13.0-alpha13" +androidGradlePlugin = "8.9.2" +androidx-activity-compose = "1.10.1" +androidx-appcompat = "1.7.0" +androidx-compose-bom = "2025.04.01" +androidx-constraintlayout = "1.1.0" +androidx-core-splashscreen = "1.0.1" +androidx-corektx = "1.16.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.9" +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.2" +google-maps = "18.2.0" +gradle-versions = "0.52.0" +hilt = "2.56.2" +hiltExt = "1.2.0" +horologist = "0.6.23" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.1.20" +kotlinx-serialization-json = "1.7.3" +kotlinx_immutable = "0.3.8" +ksp = "2.1.20-2.0.0" +maps-compose = "3.1.1" +# @keep +minSdk = "21" +okhttp = "4.12.0" +play-services-wearable = "18.1.0" +robolectric = "4.14.1" +roborazzi = "1.43.1" +rome = "2.1.0" +room = "2.7.1" +secrets = "2.0.1" +spotless = "7.0.3" +# @keep +targetSdk = "33" +version-catalog-update = "1.0.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = "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" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/JetNews/gradle/wrapper/gradle-wrapper.jar b/JetNews/gradle/wrapper/gradle-wrapper.jar index 62d4c05355..7454180f2a 100644 Binary files a/JetNews/gradle/wrapper/gradle-wrapper.jar and b/JetNews/gradle/wrapper/gradle-wrapper.jar differ diff --git a/JetNews/gradle/wrapper/gradle-wrapper.properties b/JetNews/gradle/wrapper/gradle-wrapper.properties index cfb40e4707..d6c8bc7bf8 100644 --- a/JetNews/gradle/wrapper/gradle-wrapper.properties +++ b/JetNews/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,19 @@ -#Tue Oct 27 16:21:59 PDT 2020 +# 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 -distributionUrl=https://services.gradle.org/distributions/gradle-6.8-rc-1-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/JetNews/gradlew b/JetNews/gradlew index fbd7c51583..744e882ed5 100755 --- a/JetNews/gradlew +++ b/JetNews/gradlew @@ -72,7 +72,7 @@ case "`uname`" in Darwin* ) darwin=true ;; - MINGW* ) + MSYS* | MINGW* ) msys=true ;; NONSTOP* ) @@ -130,7 +130,7 @@ fi 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 diff --git a/JetNews/gradlew.bat b/JetNews/gradlew.bat index a9f778a7a9..ac1b06f938 100644 --- a/JetNews/gradlew.bat +++ b/JetNews/gradlew.bat @@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -54,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -64,21 +64,6 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line @@ -86,7 +71,7 @@ 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 %CMD_LINE_ARGS% +"%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 diff --git a/JetNews/screenshots/jetnews_all_screens.png b/JetNews/screenshots/jetnews_all_screens.png new file mode 100644 index 0000000000..74119a067f Binary files /dev/null and b/JetNews/screenshots/jetnews_all_screens.png differ diff --git a/JetNews/screenshots/jetnews_demo.gif b/JetNews/screenshots/jetnews_demo.gif index df55331611..b3964d7f47 100644 Binary files a/JetNews/screenshots/jetnews_demo.gif and b/JetNews/screenshots/jetnews_demo.gif differ diff --git a/JetNews/screenshots/jetnews_glance_appwidget.png b/JetNews/screenshots/jetnews_glance_appwidget.png new file mode 100644 index 0000000000..9d2eb7f060 Binary files /dev/null and b/JetNews/screenshots/jetnews_glance_appwidget.png differ diff --git a/JetNews/screenshots/screenshots.png b/JetNews/screenshots/screenshots.png new file mode 100644 index 0000000000..7feed9a51b Binary files /dev/null and b/JetNews/screenshots/screenshots.png differ diff --git a/JetNews/settings.gradle b/JetNews/settings.gradle deleted file mode 100644 index b2268f99dc..0000000000 --- a/JetNews/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -include ':app' -rootProject.name='JetNews' diff --git a/JetNews/settings.gradle.kts b/JetNews/settings.gradle.kts new file mode 100644 index 0000000000..56625726e3 --- /dev/null +++ b/JetNews/settings.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +val snapshotVersion : String? = System.getenv("COMPOSE_SNAPSHOT_ID") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + snapshotVersion?.let { + println("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") + maven { url = uri("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") } + } + + google() + mavenCentral() + } +} +rootProject.name = "JetNews" +include(":app") + diff --git a/Jetcaster/.gitignore b/Jetcaster/.gitignore index b5c3f152f9..a1f10cd8b3 100644 --- a/Jetcaster/.gitignore +++ b/Jetcaster/.gitignore @@ -21,3 +21,4 @@ gradle.xml # General .DS_Store .externalNativeBuild +.kotlin/ diff --git a/Jetcaster/.google/packaging.yaml b/Jetcaster/.google/packaging.yaml index 1d49c703c6..ee996287bb 100644 --- a/Jetcaster/.google/packaging.yaml +++ b/Jetcaster/.google/packaging.yaml @@ -18,12 +18,23 @@ # End users may safely ignore this file. It has no relevance to other systems. --- status: PUBLISHED -technologies: [Android] -categories: [Compose] +technologies: [Android, JetpackCompose, Coroutines] +categories: + - AndroidArchitectureUILayer + - JetpackComposeDesignSystems + - JetpackComposeAnimation + - Android TV languages: [Kotlin] -solutions: [Mobile] +solutions: + - Mobile + - Flow + - JetpackRoom + - JetpackLifecycle + - JetpackNavigation + - TV github: android/compose-samples level: ADVANCED apiRefs: - android:androidx.compose.Composable + - android:androidx.tv.material3 license: apache2 diff --git a/Jetcaster/README.md b/Jetcaster/README.md index 979fadb3bd..b4cc85cad1 100644 --- a/Jetcaster/README.md +++ b/Jetcaster/README.md @@ -3,85 +3,98 @@ # Jetcaster sample 🎙️ Jetcaster is a sample podcast app, built with [Jetpack Compose][compose]. The goal of the sample is to -showcase dynamic theming and full featured architecture. +showcase building with Compose across multiple form factors (mobile, TV, and Wear) and full featured architecture. -To try out this sample app, you need to use the latest Canary version of Android Studio 4.2. +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). You can clone this repository or import the project from Android Studio following the steps [here](https://developer.android.com/jetpack/compose/setup#sample). -### Status: 🚧 In progress 🚧 - -Jetcaster is still in the early stages of development, and as such only one screen has been created so far. However, -most of the app's architecture has been implemented, as well as the data layer, and early stages of dynamic theming. - - ## Screenshots -<img src="docs/jetcaster.gif"/> +<img src="../readme/jetcaster-hero.png"></img> -## Features +## Phone app -This sample contains 1 screen so far: the home screen. It is split into sub-screens for easy re-use: +### Features -- __Home__, allowing the user to see their followed podcasts (top carousel), and navigate between 'Your Library' and 'Discover' - - __Discover__, allowing the user to browse podcast categories - - __Podcast Category__, allowing the user to see a list of recent episodes for podcasts in a given category. +This sample has 3 components: the home screen, the podcast details screen, and the player screen -### Dynamic theming -The home screen currently implements dynamic theming, using the artwork of the currently selected podcast from the carousel to update the `primary` and `onPrimary` [colors](https://developer.android.com/reference/kotlin/androidx/compose/material/Colors). You can see it in action in the screenshots above: as the carousel item is changed, the background gradient is updated to match the artwork. +The home screen is split into sub-screens for easy re-use: -This is implemented in [`DynamicTheming.kt`](app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt), which provides the `DynamicThemePrimaryColorsFromImage` composable, to automatically animate the theme colors based on the provided image URL, like so: +- __Home__, allowing the user to see their subscribed podcasts (top carousel), and navigate between 'Your Library' and 'Discover' +- __Discover__, allowing the user to browse podcast categories +- __Podcast Category__, allowing the user to see a list of recent episodes for podcasts in a given category. -``` kotlin -val dominantColorState: DominantColorState = rememberDominantColorState() +Multiple panes will also be shown depending on the device's [window size class][wsc]. -DynamicThemePrimaryColorsFromImage(dominantColorState) { - var imageUrl = remember { mutableStateOf("") } - - // When the image url changes, call updateColorsFromImageUrl() - launchInComposition(imageUrl) { - dominantColorState.updateColorsFromImageUrl(imageUrl) - } - - // Content which will be dynamically themed.... -} -``` +The player screen displays media controls and the currently "playing" podcast (the sample currently **does not** actually play any media—the behavior is simply mocked). +The player screen layout is adapting to different form factors, including a tabletop layout on foldable devices: -Underneath, [`DominantColorState`](app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt) uses the [Coil][coil] library to fetch the artwork image 🖼️, and then [Palette][palette] to extract the dominant colors from the image 🎨. + ### Others Some other notable things which are implemented: -* [`WindowInsets`](https://developer.android.com/reference/kotlin/android/view/WindowInsets) support is provided from this [experimental implementation](https://gist.github.com/chrisbanes/14f9184e7b22299203037df739a6512b). Support will likely will added to the Compose libraries in the future. -* Images are all provided from each podcast's RSS feed, and loaded using [accompanist-coil](https://github.com/chrisbanes/accompanist). +* Images are all provided from each podcast's RSS feed, and loaded using [Coil][coil] library. -## Architecture +### Architecture The app is built in a Redux-style, where each UI 'screen' has its own [ViewModel][viewmodel], which exposes a single [StateFlow][stateflow] containing the entire view state. Each [ViewModel][viewmodel] is responsible for subscribing to any data streams required for the view, as well as exposing functions which allow the UI to send events. -Using the example of the home screen in the [`com.example.jetcaster.ui.home`](app/src/main/java/com/example/jetcaster/ui/home) package: +Using the example of the home screen in the [`com.example.jetcaster.ui.home`](mobile/src/main/java/com/example/jetcaster/ui/home) package: - The ViewModel is implemented as [`HomeViewModel`][homevm], which exposes a `StateFlow<HomeViewState>` for the UI to observe. - [`HomeViewState`][homevm] contains the complete view state for the home screen as an [`@Immutable`](https://developer.android.com/reference/kotlin/androidx/compose/runtime/Immutable) `data class`. - - The Home Compose UI in [`Home.kt`][homeui] uses [`HomeViewModel`][homevm], and observes it's [`HomeViewState`][homevm] as Compose [State](https://developer.android.com/reference/kotlin/androidx/compose/runtime/State), using [`collectAsState()`](https://developer.android.com/reference/kotlin/androidx/compose/package-summary#collectasstate): + - The Home Compose UI in [`Home.kt`][homeui] uses [`HomeViewModel`][homevm], and observes it's [`HomeViewState`][homevm] as Compose [State](https://developer.android.com/reference/kotlin/androidx/compose/runtime/State), using [`collectAsStateWithLifecycle()`](https://developer.android.com/reference/kotlin/androidx/lifecycle/compose/package-summary#(kotlinx.coroutines.flow.StateFlow).collectAsStateWithLifecycle(androidx.lifecycle.LifecycleOwner,androidx.lifecycle.Lifecycle.State,kotlin.coroutines.CoroutineContext)): ``` kotlin val viewModel: HomeViewModel = viewModel() -val viewState by viewModel.state.collectAsState() +val viewState by viewModel.state.collectAsStateWithLifecycle() ``` This pattern is used across the different screens: -- __Home:__ [`com.example.jetcaster.ui.home`](app/src/main/java/com/example/jetcaster/ui/home) -- __Discover:__ [`com.example.jetcaster.ui.home.discover`](app/src/main/java/com/example/jetcaster/ui/home/discover) -- __Podcast Category:__ [`com.example.jetcaster.ui.category`](app/src/main/java/com/example/jetcaster/ui/home/category) +- __Home:__ [`com.example.jetcaster.ui.home`](mobile/src/main/java/com/example/jetcaster/ui/home) +- __Discover:__ [`com.example.jetcaster.ui.home.discover`](mobile/src/main/java/com/example/jetcaster/ui/home/discover) +- __Podcast Category:__ [`com.example.jetcaster.ui.category`](mobile/src/main/java/com/example/jetcaster/ui/home/category) + +## Wear + +This sample showcases a 2-screen pager which allows navigation between the Player and the Library. +From the Library, users can access latest episodes from subscribed podcasts, and queue. +From the podcast, users can access episode details and add episodes to the queue. +From the Player screen, users can access a volume screen and a playback speed screen. + +The sample implements [Wear UX best practices for media apps][mediappsbestpractices], such as: +- Support rotating side button (RSB) and Bezel for scrollable screens +- Display scrollbar on scrolling +- Display the time on top of the screens + +The sample is built using the [Media Toolkit][mediatoolkit] which is an open source +project part of [Horologist][horologist] to ease the development of media apps on Wear OS built on top of Compose for Wear. +It provides ready to use UI screens, such the [EntityScreen][entityscreen] +that is used in this sample to implement many screens such as Podcast, LatestEpisodes and Queue. [Horologist][horologist] also provides +a VolumeScreen that can be reused by media apps to conveniently control volume either by interacting with the rotating side button(RSB)/Bezel or by +using the provided buttons. +For simplicity, this sample uses a mock Player which is reused across form factors, +if you want to see an advanced Media sample built on Compose that uses Exoplayer and plays media content, +refer to the [Media Toolkit sample][mediatoolkitsample]. + +The [official media app guidance for Wear OS][wearmediaguidance] +recommends downloading content onto the watch before listening to preserve power, this feature will be added to this sample in future iterations. You can +refer to the [Media Toolkit sample][mediatoolkitsample] to learn how to implement the media download feature. + +### Architecture +The architecture of the Wear app is similar to the phone app architecture: each UI 'screen' has its +own [ViewModel][viewmodel] which exposes a `StateFlow<ScreenState>` for the UI to observe. ## Data ### Podcast data -The podcast data in this sample is dynamically fetched from a number of podcast RSS feeds, which are listed in [`Feeds.kt`](app/src/main/java/com/example/jetcaster/data/Feeds.kt). +The podcast data in this sample is dynamically fetched from a number of podcast RSS feeds, which are listed in [`Feeds.kt`](core/data/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt). The [`PodcastRepository`][podcastrepo] class is responsible for handling the data fetching of all podcast information: @@ -90,11 +103,11 @@ The [`PodcastRepository`][podcastrepo] class is responsible for handling the dat ### Follow podcasts - The sample allows users to 'follow' podcasts, which is implemented within the data layer in the [`PodcastFollowedEntry`](app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt) entity class, and as functions in [PodcastStore][podcaststore]: `followPodcast()`, `unfollowPodcast()`. + The sample allows users to 'follow' podcasts, which is implemented within the data layer in the [`PodcastFollowedEntry`](core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt) entity class, and as functions in [PodcastStore][podcaststore]: `followPodcast()`, `unfollowPodcast()`. ### Date + time - The sample uses the JDK 8 [date and time APIs](https://developer.android.com/reference/java/time/package-summary) through the [desugaring support][jdk8desugar] available in Android Gradle Plugin 4.0+. Relevant Room [`TypeConverters`](https://developer.android.com/reference/kotlin/androidx/room/TypeConverters) are implemented in [`DateTimeTypeConverters.kt`](app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt). + The sample uses the JDK 8 [date and time APIs](https://developer.android.com/reference/java/time/package-summary) through the [desugaring support][jdk8desugar] available in Android Gradle Plugin 4.0+. Relevant Room [`TypeConverters`](https://developer.android.com/reference/kotlin/androidx/room/TypeConverters) are implemented in [`DateTimeTypeConverters.kt`](core/data/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt). ## License @@ -114,15 +127,16 @@ See the License for the specific language governing permissions and limitations under the License. ``` - [feeds]: app/src/main/java/com/example/jetcaster/data/Feeds.kt - [fetcher]: app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt - [podcastrepo]: app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt - [podcaststore]: app/src/main/java/com/example/jetcaster/data/PodcastStore.kt - [epstore]: app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt - [catstore]: app/src/main/java/com/example/jetcaster/data/CategoryStore.kt - [db]: app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt - [homevm]: app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt - [homeui]: app/src/main/java/com/example/jetcaster/ui/home/Home.kt + [feeds]: mobile/src/main/java/com/example/jetcaster/data/Feeds.kt + [fetcher]: mobile/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt + [podcastrepo]: mobile/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt + [podcaststore]: mobile/src/main/java/com/example/jetcaster/data/PodcastStore.kt + [epstore]: mobile/src/main/java/com/example/jetcaster/data/EpisodeStore.kt + [catstore]: mobile/src/main/java/com/example/jetcaster/data/CategoryStore.kt + [db]: mobile/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt + [glance]: https://developer.android.com/develop/ui/compose/glance + [homevm]: mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt + [homeui]: mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt [compose]: https://developer.android.com/jetpack/compose [palette]: https://developer.android.com/reference/kotlin/androidx/palette/graphics/package-summary [room]: https://developer.android.com/topic/libraries/architecture/room @@ -132,4 +146,10 @@ limitations under the License. [rome]: https://rometools.github.io/rome/ [jdk8desugar]: https://developer.android.com/studio/write/java8-support#library-desugaring [coil]: https://coil-kt.github.io/coil/ - \ No newline at end of file + [wsc]: https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes#window_size_classes + [mediatoolkit]: https://google.github.io/horologist/media-toolkit/ + [mediatoolkitsample]: https://google.github.io/horologist/media-sample/ + [wearmediaguidance]: https://developer.android.com/media/implement/surfaces/wear-os#play-downloaded-content + [horologist]: https://google.github.io/horologist/ + [entityscreen]: https://github.com/google/horologist/blob/main/media/ui/src/main/java/com/google/android/horologist/media/ui/screens/entity/EntityScreen.kt + [mediappsbestpractices]: https://developer.android.com/design/ui/wear/guides/foundations/media-apps diff --git a/Jetcaster/app/build.gradle b/Jetcaster/app/build.gradle deleted file mode 100644 index f0e3b8bf28..0000000000 --- a/Jetcaster/app/build.gradle +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.jetcaster.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' - id 'kotlin-kapt' -} - -kapt { - correctErrorTypes = true - useBuildCache = true -} - -android { - compileSdkVersion 30 - - defaultConfig { - applicationId 'com.example.jetcaster' - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName '1.0' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - - javaCompileOptions { - annotationProcessorOptions { - arguments = [ - "room.incremental" : "true", - "room.expandProjection": "true" - ] - } - } - } - - packagingOptions { - // 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. - exclude "/*.jar" - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - // Flag to enable support for the new language APIs - coreLibraryDesugaringEnabled true - - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - buildFeatures { - compose true - } - - composeOptions { - kotlinCompilerVersion Libs.Kotlin.version - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - implementation Libs.AndroidX.coreKtx - implementation Libs.AndroidX.palette - - implementation Libs.AndroidX.Lifecycle.viewmodel - - implementation Libs.AndroidX.Compose.foundation - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.materialIconsExtended - implementation Libs.AndroidX.Compose.tooling - - implementation Libs.Accompanist.coil - - implementation Libs.Accompanist.insets - - implementation Libs.OkHttp.okhttp - implementation Libs.OkHttp.logging - - implementation Libs.Rome.rome - implementation Libs.Rome.modules - - implementation Libs.AndroidX.Room.runtime - implementation Libs.AndroidX.Room.ktx - kapt Libs.AndroidX.Room.compiler - - coreLibraryDesugaring Libs.jdkDesugar -} diff --git a/Jetcaster/app/proguard-rules.pro b/Jetcaster/app/proguard-rules.pro deleted file mode 100644 index cdd67acb1a..0000000000 --- a/Jetcaster/app/proguard-rules.pro +++ /dev/null @@ -1,38 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile - -# Repackage classes into the top-level. --repackageclasses - -# Rome reflectively loads classes referenced in com/rometools/rome/rome.properties. --adaptresourcefilecontents com/rometools/rome/rome.properties --keep,allowobfuscation class * implements com.rometools.rome.feed.synd.Converter --keep,allowobfuscation class * implements com.rometools.rome.io.ModuleParser --keep,allowobfuscation 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 diff --git a/Jetcaster/app/src/main/AndroidManifest.xml b/Jetcaster/app/src/main/AndroidManifest.xml deleted file mode 100644 index e3fa0b2ce0..0000000000 --- a/Jetcaster/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,44 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - --> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.example.jetcaster"> - - <!-- Uses ACCESS_NETWORK_STATE to check if the device is connected to internet or not --> - <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> - <!-- Uses INTERNET to fetch RSS feed + images --> - <uses-permission android:name="android.permission.INTERNET" /> - - <application - android:name=".JetcasterApplication" - android:allowBackup="true" - android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" - android:supportsRtl="true" - android:theme="@style/Theme.Jetcaster" - android:usesCleartextTraffic="true"> - <activity - android:name="com.example.jetcaster.ui.MainActivity" - android:label="@string/app_name" - android:theme="@style/Theme.Jetcaster"> - <intent-filter> - <action android:name="android.intent.action.MAIN" /> - <category android:name="android.intent.category.LAUNCHER" /> - </intent-filter> - </activity> - </application> - -</manifest> diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt deleted file mode 100644 index 612a6349f2..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/Graph.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster - -import android.content.Context -import androidx.room.Room -import com.example.jetcaster.data.CategoryStore -import com.example.jetcaster.data.EpisodeStore -import com.example.jetcaster.data.PodcastStore -import com.example.jetcaster.data.PodcastsFetcher -import com.example.jetcaster.data.PodcastsRepository -import com.example.jetcaster.data.room.JetcasterDatabase -import com.example.jetcaster.data.room.TransactionRunner -import com.rometools.rome.io.SyndFeedInput -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import okhttp3.Cache -import okhttp3.OkHttpClient -import okhttp3.logging.LoggingEventListener -import java.io.File - -/** - * A very simple global singleton dependency graph. - * - * For a real app, you would use something like Hilt/Dagger instead. - */ -object Graph { - lateinit var okHttpClient: OkHttpClient - - lateinit var database: JetcasterDatabase - private set - - private val transactionRunner: TransactionRunner - get() = database.transactionRunnerDao() - - private val syndFeedInput by lazy { SyndFeedInput() } - - val podcastRepository by lazy { - PodcastsRepository( - podcastsFetcher = podcastFetcher, - podcastStore = podcastStore, - episodeStore = episodeStore, - categoryStore = categoryStore, - transactionRunner = transactionRunner, - mainDispatcher = mainDispatcher - ) - } - - private val podcastFetcher by lazy { - PodcastsFetcher( - okHttpClient = okHttpClient, - syndFeedInput = syndFeedInput, - ioDispatcher = ioDispatcher - ) - } - - val podcastStore by lazy { - PodcastStore( - podcastDao = database.podcastsDao(), - podcastFollowedEntryDao = database.podcastFollowedEntryDao(), - transactionRunner = transactionRunner - ) - } - - private val episodeStore by lazy { - EpisodeStore( - episodesDao = database.episodesDao() - ) - } - - val categoryStore by lazy { - CategoryStore( - categoriesDao = database.categoriesDao(), - categoryEntryDao = database.podcastCategoryEntryDao(), - episodesDao = database.episodesDao(), - podcastsDao = database.podcastsDao() - ) - } - - private val mainDispatcher: CoroutineDispatcher - get() = Dispatchers.Main - - private val ioDispatcher: CoroutineDispatcher - get() = Dispatchers.IO - - fun provide(context: Context) { - okHttpClient = OkHttpClient.Builder() - .cache(Cache(File(context.cacheDir, "http_cache"), 20 * 1024 * 1024)) - .apply { - if (BuildConfig.DEBUG) eventListenerFactory(LoggingEventListener.Factory()) - } - .build() - - database = Room.databaseBuilder(context, JetcasterDatabase::class.java, "data.db") - // This is not recommended for normal apps, but the goal of this sample isn't to - // showcase all of Room. - .fallbackToDestructiveMigration() - .build() - } -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt deleted file mode 100644 index 2629ad81aa..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/JetcasterApplication.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster - -import android.app.Application - -/** - * Application which sets up our dependency [Graph] with a context. - */ -class JetcasterApplication : Application() { - override fun onCreate() { - super.onCreate() - Graph.provide(this) - } -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/CategoryStore.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/CategoryStore.kt deleted file mode 100644 index cabf7e9e29..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/CategoryStore.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.data - -import com.example.jetcaster.data.room.CategoriesDao -import com.example.jetcaster.data.room.EpisodesDao -import com.example.jetcaster.data.room.PodcastCategoryEntryDao -import com.example.jetcaster.data.room.PodcastsDao -import kotlinx.coroutines.flow.Flow - -/** - * A data repository for [Category] instances. - */ -class CategoryStore( - private val categoriesDao: CategoriesDao, - private val categoryEntryDao: PodcastCategoryEntryDao, - private val episodesDao: EpisodesDao, - private val podcastsDao: PodcastsDao -) { - /** - * Returns a flow containing a list of categories which is sorted by the number - * of podcasts in each category. - */ - fun categoriesSortedByPodcastCount( - limit: Int = Integer.MAX_VALUE - ): Flow<List<Category>> { - return categoriesDao.categoriesSortedByPodcastCount(limit) - } - - /** - * Returns a flow containing a list of podcasts in the category with the given [categoryId], - * sorted by the their last episode date. - */ - fun podcastsInCategorySortedByPodcastCount( - categoryId: Long, - limit: Int = Int.MAX_VALUE - ): Flow<List<PodcastWithExtraInfo>> { - return podcastsDao.podcastsInCategorySortedByLastEpisode(categoryId, limit) - } - - /** - * Returns a flow containing a list of episodes from podcasts in the category with the - * given [categoryId], sorted by the their last episode date. - */ - fun episodesFromPodcastsInCategory( - categoryId: Long, - limit: Int = Integer.MAX_VALUE - ): Flow<List<EpisodeToPodcast>> { - return episodesDao.episodesFromPodcastsInCategory(categoryId, limit) - } - - /** - * Adds the category to the database if it doesn't already exist. - * - * @return the id of the newly inserted/existing category - */ - suspend fun addCategory(category: Category): Long { - return when (val local = categoriesDao.getCategoryWithName(category.name)) { - null -> categoriesDao.insert(category) - else -> local.id - } - } - - suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) { - categoryEntryDao.insert( - PodcastCategoryEntry(podcastUri = podcastUri, categoryId = categoryId) - ) - } -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt deleted file mode 100644 index 42af925598..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeStore.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.data - -import com.example.jetcaster.data.room.EpisodesDao -import kotlinx.coroutines.flow.Flow - -/** - * A data repository for [Episode] instances. - */ -class EpisodeStore( - private val episodesDao: EpisodesDao -) { - /** - * Returns a flow containing the list of episodes associated with the podcast with the - * given [podcastUri]. - */ - fun episodesInPodcast( - podcastUri: String, - limit: Int = Integer.MAX_VALUE - ): Flow<List<Episode>> { - return episodesDao.episodesForPodcastUri(podcastUri, limit) - } - - /** - * Add a new [Episode] to this store. - * - * This automatically switches to the main thread to maintain thread consistency. - */ - suspend fun addEpisodes(episodes: Collection<Episode>) = episodesDao.insertAll(episodes) - - suspend fun isEmpty(): Boolean = episodesDao.count() == 0 -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt deleted file mode 100644 index f1c5d3ad5e..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFetcher.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.data - -import coil.network.HttpException -import com.rometools.modules.itunes.EntryInformation -import com.rometools.modules.itunes.FeedInformation -import com.rometools.rome.feed.synd.SyndEntry -import com.rometools.rome.feed.synd.SyndFeed -import com.rometools.rome.io.SyndFeedInput -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.flatMapMerge -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext -import okhttp3.CacheControl -import okhttp3.OkHttpClient -import okhttp3.Request -import java.time.Duration -import java.time.Instant -import java.time.ZoneOffset -import java.util.concurrent.TimeUnit - -/** - * A class which fetches some selected podcast RSS feeds. - * - * @param okHttpClient [OkHttpClient] to use for network requests - * @param syndFeedInput [SyndFeedInput] to use for parsing RSS feeds. - * @param ioDispatcher [CoroutineDispatcher] to use for running fetch requests. - */ -class PodcastsFetcher( - private val okHttpClient: OkHttpClient, - private val syndFeedInput: SyndFeedInput, - private val ioDispatcher: CoroutineDispatcher -) { - - /** - * It seems that most podcast hosts do not implement HTTP caching appropriately. - * Instead of fetching data on every app open, we instead allow the use of 'stale' - * network responses (up to 8 hours). - */ - private val cacheControl by lazy { - CacheControl.Builder().maxStale(8, TimeUnit.HOURS).build() - } - - /** - * Returns a [Flow] which fetches each podcast feed and emits it in turn. - * - * The feeds are fetched concurrently, meaning that the resulting emission order may not - * match the order of [feedUrls]. - */ - operator fun invoke(feedUrls: List<String>): Flow<PodcastRssResponse> = feedUrls.asFlow() - // We use flatMapMerge here to achieve concurrent fetching/parsing of the feeds. - .flatMapMerge { feedUrl -> - flow { emit(fetchPodcast(feedUrl)) } - } - - private suspend fun fetchPodcast(url: String): PodcastRssResponse { - val request = Request.Builder() - .url(url) - .cacheControl(cacheControl) - .build() - - val response = okHttpClient.newCall(request).await() - - // If the network request wasn't successful, throw an exception - if (!response.isSuccessful) throw HttpException(response) - - // Otherwise we can parse the response using a Rome SyndFeedInput, then map it - // to a Podcast instance. We run this on the IO dispatcher since the parser is reading - // from a stream. - return withContext(ioDispatcher) { - response.body!!.use { body -> - syndFeedInput.build(body.charStream()).toPodcastResponse(url) - } - } - } -} - -data class PodcastRssResponse( - val podcast: Podcast, - val episodes: List<Episode>, - val categories: Set<Category> -) - -/** - * Map a Rome [SyndFeed] instance to our own [Podcast] data class. - */ -private fun SyndFeed.toPodcastResponse(feedUrl: String): PodcastRssResponse { - val podcastUri = uri ?: feedUrl - val episodes = entries.map { it.toEpisode(podcastUri) } - - val feedInfo = getModule(PodcastModuleDtd) as? FeedInformation - val podcast = Podcast( - uri = podcastUri, - title = title, - description = feedInfo?.summary ?: description, - author = author, - copyright = copyright, - imageUrl = feedInfo?.imageUri?.toString() - ) - - val categories = feedInfo?.categories - ?.map { Category(name = it.name) } - ?.toSet() ?: emptySet() - - return PodcastRssResponse(podcast, episodes, categories) -} - -/** - * Map a Rome [SyndEntry] instance to our own [Episode] data class. - */ -private fun SyndEntry.toEpisode(podcastUri: String): Episode { - val entryInformation = getModule(PodcastModuleDtd) as? EntryInformation - return Episode( - uri = uri, - podcastUri = podcastUri, - title = title, - author = author, - summary = entryInformation?.summary ?: description?.value, - subtitle = entryInformation?.subtitle, - published = Instant.ofEpochMilli(publishedDate.time).atOffset(ZoneOffset.UTC), - duration = entryInformation?.duration?.milliseconds?.let { Duration.ofMillis(it) } - ) -} - -/** - * Most feeds use the following DTD to include extra information related to - * their podcast. Info such as images, summaries, duration, categories is sometimes only available - * via this attributes in this DTD. - */ -private const val PodcastModuleDtd = "http://www.itunes.com/dtds/podcast-1.0.dtd" diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt deleted file mode 100644 index b9ace6b52e..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastStore.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.data - -import com.example.jetcaster.data.room.PodcastFollowedEntryDao -import com.example.jetcaster.data.room.PodcastsDao -import com.example.jetcaster.data.room.TransactionRunner -import kotlinx.coroutines.flow.Flow - -/** - * A data repository for [Podcast] instances. - */ -class PodcastStore( - private val podcastDao: PodcastsDao, - private val podcastFollowedEntryDao: PodcastFollowedEntryDao, - private val transactionRunner: TransactionRunner -) { - /** - * Return a flow containing the [Podcast] with the given [uri]. - */ - fun podcastWithUri(uri: String): Flow<Podcast> { - return podcastDao.podcastWithUri(uri) - } - - /** - * Returns a flow containing the entire collection of podcasts, sorted by the last episode - * publish date for each podcast. - */ - fun podcastsSortedByLastEpisode( - limit: Int = Int.MAX_VALUE - ): Flow<List<PodcastWithExtraInfo>> { - return podcastDao.podcastsSortedByLastEpisode(limit) - } - - /** - * Returns a flow containing a list of all followed podcasts, sorted by the their last - * episode date. - */ - fun followedPodcastsSortedByLastEpisode( - limit: Int = Int.MAX_VALUE - ): Flow<List<PodcastWithExtraInfo>> { - return podcastDao.followedPodcastsSortedByLastEpisode(limit) - } - - private suspend fun followPodcast(podcastUri: String) { - podcastFollowedEntryDao.insert(PodcastFollowedEntry(podcastUri = podcastUri)) - } - - suspend fun togglePodcastFollowed(podcastUri: String) = transactionRunner { - if (podcastFollowedEntryDao.isPodcastFollowed(podcastUri)) { - unfollowPodcast(podcastUri) - } else { - followPodcast(podcastUri) - } - } - - suspend fun unfollowPodcast(podcastUri: String) { - podcastFollowedEntryDao.deleteWithPodcastUri(podcastUri) - } - - /** - * Add a new [Podcast] to this store. - * - * This automatically switches to the main thread to maintain thread consistency. - */ - suspend fun addPodcast(podcast: Podcast) { - podcastDao.insert(podcast) - } - - suspend fun isEmpty(): Boolean = podcastDao.count() == 0 -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt deleted file mode 100644 index 84fef6092b..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastsRepository.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS 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.data - -import android.util.Log -import com.example.jetcaster.data.room.TransactionRunner -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch - -/** - * Data repository for Podcasts. - */ -class PodcastsRepository( - private val podcastsFetcher: PodcastsFetcher, - private val podcastStore: PodcastStore, - private val episodeStore: EpisodeStore, - private val categoryStore: CategoryStore, - private val transactionRunner: TransactionRunner, - mainDispatcher: CoroutineDispatcher -) { - private var refreshingJob: Job? = null - - private val scope = CoroutineScope(mainDispatcher) - - suspend fun updatePodcasts(force: Boolean) { - if (refreshingJob?.isActive == true) { - refreshingJob?.join() - } else if (force || podcastStore.isEmpty()) { - refreshingJob = scope.launch { - try { - // Now fetch the podcasts, and add each to each store - podcastsFetcher(SampleFeeds).collect { (podcast, episodes, categories) -> - transactionRunner { - podcastStore.addPodcast(podcast) - episodeStore.addEpisodes(episodes) - - categories.forEach { category -> - // First insert the category - val categoryId = categoryStore.addCategory(category) - // Now we can add the podcast to the category - categoryStore.addPodcastToCategory( - podcastUri = podcast.uri, - categoryId = categoryId - ) - } - } - } - } catch (e: Throwable) { - Log.d("PodcastsRepository", "podcastsFetcher(SampleFeeds).collect error: $e") - } - } - } - } -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/CategoriesDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/CategoriesDao.kt deleted file mode 100644 index d21d28d65b..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/CategoriesDao.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS 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.data.room - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Update -import com.example.jetcaster.data.Category -import kotlinx.coroutines.flow.Flow - -/** - * [Room] DAO for [Category] related operations. - */ -@Dao -abstract class CategoriesDao { - @Query( - """ - SELECT categories.* FROM categories - INNER JOIN ( - SELECT category_id, COUNT(podcast_uri) AS podcast_count FROM podcast_category_entries - GROUP BY category_id - ) ON category_id = categories.id - ORDER BY podcast_count DESC - LIMIT :limit - """ - ) - abstract fun categoriesSortedByPodcastCount( - limit: Int - ): Flow<List<Category>> - - @Query("SELECT * FROM categories WHERE name = :name") - abstract suspend fun getCategoryWithName(name: String): Category? - - /** - * The following methods should really live in a base interface. Unfortunately the Kotlin - * Compiler which we need to use for Compose doesn't work with that. - * TODO: remove this once we move to a more recent Kotlin compiler - */ - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insert(entity: Category): Long - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(vararg entity: Category) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(entities: Collection<Category>) - - @Update(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun update(entity: Category) - - @Delete - abstract suspend fun delete(entity: Category): Int -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/EpisodesDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/EpisodesDao.kt deleted file mode 100644 index b1c0335a3d..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/EpisodesDao.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.data.room - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import com.example.jetcaster.data.Episode -import com.example.jetcaster.data.EpisodeToPodcast -import kotlinx.coroutines.flow.Flow - -/** - * [Room] DAO for [Episode] related operations. - */ -@Dao -abstract class EpisodesDao { - - @Query( - """ - SELECT * FROM episodes WHERE podcast_uri = :podcastUri - ORDER BY datetime(published) DESC - LIMIT :limit - """ - ) - abstract fun episodesForPodcastUri( - podcastUri: String, - limit: Int - ): Flow<List<Episode>> - - @Transaction - @Query( - """ - SELECT episodes.* FROM episodes - INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri - WHERE category_id = :categoryId - ORDER BY datetime(published) DESC - LIMIT :limit - """ - ) - abstract fun episodesFromPodcastsInCategory( - categoryId: Long, - limit: Int - ): Flow<List<EpisodeToPodcast>> - - @Query("SELECT COUNT(*) FROM episodes") - abstract suspend fun count(): Int - - /** - * The following methods should really live in a base interface. Unfortunately the Kotlin - * Compiler which we need to use for Compose doesn't work with that. - * TODO: remove this once we move to a more recent Kotlin compiler - */ - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insert(entity: Episode): Long - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(vararg entity: Episode) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(entities: Collection<Episode>) - - @Update(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun update(entity: Episode) - - @Delete - abstract suspend fun delete(entity: Episode): Int -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt deleted file mode 100644 index cc4f2a24e7..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/JetcasterDatabase.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.data.room - -import androidx.room.Database -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import com.example.jetcaster.data.Category -import com.example.jetcaster.data.Episode -import com.example.jetcaster.data.Podcast -import com.example.jetcaster.data.PodcastCategoryEntry -import com.example.jetcaster.data.PodcastFollowedEntry - -/** - * The [RoomDatabase] we use in this app. - */ -@Database( - entities = [ - Podcast::class, - Episode::class, - PodcastCategoryEntry::class, - Category::class, - PodcastFollowedEntry::class - ], - version = 1, - exportSchema = false -) -@TypeConverters(DateTimeTypeConverters::class) -abstract class JetcasterDatabase : RoomDatabase() { - abstract fun podcastsDao(): PodcastsDao - abstract fun episodesDao(): EpisodesDao - abstract fun categoriesDao(): CategoriesDao - abstract fun podcastCategoryEntryDao(): PodcastCategoryEntryDao - abstract fun transactionRunnerDao(): TransactionRunnerDao - abstract fun podcastFollowedEntryDao(): PodcastFollowedEntryDao -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastCategoryEntryDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastCategoryEntryDao.kt deleted file mode 100644 index 5dad731f0e..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastCategoryEntryDao.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.data.room - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Update -import com.example.jetcaster.data.PodcastCategoryEntry - -/** - * [Room] DAO for [PodcastCategoryEntry] related operations. - */ -@Dao -abstract class PodcastCategoryEntryDao { - /** - * The following methods should really live in a base interface. Unfortunately the Kotlin - * Compiler which we need to use for Compose doesn't work with that. - * TODO: remove this once we move to a more recent Kotlin compiler - */ - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insert(entity: PodcastCategoryEntry): Long - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(vararg entity: PodcastCategoryEntry) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(entities: Collection<PodcastCategoryEntry>) - - @Update(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun update(entity: PodcastCategoryEntry) - - @Delete - abstract suspend fun delete(entity: PodcastCategoryEntry): Int -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastFollowedEntryDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastFollowedEntryDao.kt deleted file mode 100644 index 2a56b3d63c..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastFollowedEntryDao.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.data.room - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Update -import com.example.jetcaster.data.PodcastFollowedEntry - -@Dao -abstract class PodcastFollowedEntryDao { - @Query("DELETE FROM podcast_followed_entries WHERE podcast_uri = :podcastUri") - abstract suspend fun deleteWithPodcastUri(podcastUri: String) - - @Query("SELECT COUNT(*) FROM podcast_followed_entries WHERE podcast_uri = :podcastUri") - protected abstract suspend fun podcastFollowRowCount(podcastUri: String): Int - - suspend fun isPodcastFollowed(podcastUri: String): Boolean { - return podcastFollowRowCount(podcastUri) > 0 - } - - /** - * The following methods should really live in a base interface. Unfortunately the Kotlin - * Compiler which we need to use for Compose doesn't work with. - * TODO: remove this once we move to a more recent Kotlin compiler - */ - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insert(entity: PodcastFollowedEntry): Long - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(vararg entity: PodcastFollowedEntry) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(entities: Collection<PodcastFollowedEntry>) - - @Update(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun update(entity: PodcastFollowedEntry) - - @Delete - abstract suspend fun delete(entity: PodcastFollowedEntry): Int -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastsDao.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastsDao.kt deleted file mode 100644 index 6ae0baa2bb..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/PodcastsDao.kt +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.data.room - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import com.example.jetcaster.data.Podcast -import com.example.jetcaster.data.PodcastWithExtraInfo -import kotlinx.coroutines.flow.Flow - -/** - * [Room] DAO for [Podcast] related operations. - */ -@Dao -abstract class PodcastsDao { - @Query("SELECT * FROM podcasts WHERE uri = :uri") - abstract fun podcastWithUri(uri: String): Flow<Podcast> - - @Transaction - @Query( - """ - SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed - FROM podcasts - INNER JOIN ( - SELECT podcast_uri, MAX(published) AS last_episode_date - FROM episodes - GROUP BY podcast_uri - ) episodes ON podcasts.uri = episodes.podcast_uri - LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri - ORDER BY datetime(last_episode_date) DESC - LIMIT :limit - """ - ) - abstract fun podcastsSortedByLastEpisode( - limit: Int - ): Flow<List<PodcastWithExtraInfo>> - - @Transaction - @Query( - """ - SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed - FROM podcasts - INNER JOIN ( - SELECT episodes.podcast_uri, MAX(published) AS last_episode_date - FROM episodes - INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri - WHERE category_id = :categoryId - GROUP BY episodes.podcast_uri - ) inner_query ON podcasts.uri = inner_query.podcast_uri - LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = inner_query.podcast_uri - ORDER BY datetime(last_episode_date) DESC - LIMIT :limit - """ - ) - abstract fun podcastsInCategorySortedByLastEpisode( - categoryId: Long, - limit: Int - ): Flow<List<PodcastWithExtraInfo>> - - @Transaction - @Query( - """ - SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed - FROM podcasts - INNER JOIN ( - SELECT podcast_uri, MAX(published) AS last_episode_date FROM episodes GROUP BY podcast_uri - ) episodes ON podcasts.uri = episodes.podcast_uri - INNER JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri - ORDER BY datetime(last_episode_date) DESC - LIMIT :limit - """ - ) - abstract fun followedPodcastsSortedByLastEpisode( - limit: Int - ): Flow<List<PodcastWithExtraInfo>> - - @Query("SELECT COUNT(*) FROM podcasts") - abstract suspend fun count(): Int - - /** - * The following methods should really live in a base interface. Unfortunately the Kotlin - * Compiler which we need to use for Compose doesn't work with that. - * TODO: remove this once we move to a more recent Kotlin compiler - */ - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insert(entity: Podcast): Long - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(vararg entity: Podcast) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun insertAll(entities: Collection<Podcast>) - - @Update(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun update(entity: Podcast) - - @Delete - abstract suspend fun delete(entity: Podcast): Int -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt deleted file mode 100644 index e7dacf404e..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui - -import android.content.Context -import android.net.ConnectivityManager -import androidx.compose.material.AlertDialog -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.AmbientContext -import androidx.compose.ui.res.stringResource -import com.example.jetcaster.R -import com.example.jetcaster.ui.home.Home - -@Composable -fun JetcasterApp() { - val context = AmbientContext.current - var isOnline by remember { mutableStateOf(checkIfOnline(context)) } - - // TODO: add some navigation - if (isOnline) { - Home() - } else { - OfflineDialog { isOnline = checkIfOnline(context) } - } -} - -// TODO: Use a better way to check internet connection -@Suppress("DEPRECATION") -private fun checkIfOnline(context: Context): Boolean { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetwork = cm.activeNetworkInfo - return activeNetwork?.isConnectedOrConnecting == true -} - -@Composable -fun OfflineDialog(onRetry: () -> Unit) { - AlertDialog( - onDismissRequest = {}, - title = { Text(text = stringResource(R.string.connection_error_title)) }, - text = { Text(text = stringResource(R.string.connection_error_message)) }, - confirmButton = { - TextButton(onClick = onRetry) { - Text(stringResource(R.string.retry_label)) - } - } - ) -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt deleted file mode 100644 index 881e241727..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/MainActivity.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.compose.ui.platform.setContent -import androidx.core.view.WindowCompat -import com.example.jetcaster.ui.theme.JetcasterTheme -import dev.chrisbanes.accompanist.insets.ProvideWindowInsets - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // This app draws behind the system bars, so we want to handle fitting system windows - WindowCompat.setDecorFitsSystemWindows(window, false) - - setContent { - JetcasterTheme { - ProvideWindowInsets { - JetcasterApp() - } - } - } - } -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt deleted file mode 100644 index 545dfbe261..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ /dev/null @@ -1,408 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.home - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.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.preferredHeight -import androidx.compose.foundation.layout.preferredHeightIn -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Tab -import androidx.compose.material.TabDefaults.tabIndicatorOffset -import androidx.compose.material.TabPosition -import androidx.compose.material.TabRow -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.Search -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Providers -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.AmbientAnimationClock -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.viewModel -import com.example.jetcaster.R -import com.example.jetcaster.data.PodcastWithExtraInfo -import com.example.jetcaster.ui.home.discover.Discover -import com.example.jetcaster.ui.theme.JetcasterTheme -import com.example.jetcaster.ui.theme.Keyline1 -import com.example.jetcaster.util.DynamicThemePrimaryColorsFromImage -import com.example.jetcaster.util.Pager -import com.example.jetcaster.util.PagerState -import com.example.jetcaster.util.ToggleFollowPodcastIconButton -import com.example.jetcaster.util.constrastAgainst -import com.example.jetcaster.util.quantityStringResource -import com.example.jetcaster.util.rememberDominantColorState -import com.example.jetcaster.util.verticalGradientScrim -import dev.chrisbanes.accompanist.coil.CoilImage -import dev.chrisbanes.accompanist.insets.statusBarsHeight -import java.time.Duration -import java.time.LocalDateTime -import java.time.OffsetDateTime - -@Composable -fun Home() { - val viewModel: HomeViewModel = viewModel() - - val viewState by viewModel.state.collectAsState() - - Surface(Modifier.fillMaxSize()) { - HomeContent( - featuredPodcasts = viewState.featuredPodcasts, - isRefreshing = viewState.refreshing, - homeCategories = viewState.homeCategories, - selectedHomeCategory = viewState.selectedHomeCategory, - onCategorySelected = viewModel::onHomeCategorySelected, - onPodcastUnfollowed = viewModel::onPodcastUnfollowed, - modifier = Modifier.fillMaxSize() - ) - } -} - -@Composable -fun HomeAppBar( - backgroundColor: Color, - modifier: Modifier = Modifier -) { - TopAppBar( - title = { - Row { - Image( - imageVector = vectorResource(R.drawable.ic_logo) - ) - Icon( - imageVector = vectorResource(R.drawable.ic_text_logo), - modifier = Modifier.padding(start = 4.dp).preferredHeightIn(max = 24.dp) - ) - } - }, - backgroundColor = backgroundColor, - actions = { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - IconButton( - onClick = { /* TODO: Open search */ } - ) { - Icon(Icons.Filled.Search) - } - IconButton( - onClick = { /* TODO: Open account? */ } - ) { - Icon(Icons.Default.AccountCircle) - } - } - }, - modifier = modifier - ) -} - -/** - * This is the minimum amount of calculated constrast for a color to be used on top of the - * surface color. These values are defined within the WCAG AA guidelines, and we use a value of - * 3:1 which is the minimum for user-interface components. - */ -private const val MinConstastOfPrimaryVsSurface = 3f - -@Composable -fun HomeContent( - featuredPodcasts: List<PodcastWithExtraInfo>, - isRefreshing: Boolean, - selectedHomeCategory: HomeCategory, - homeCategories: List<HomeCategory>, - modifier: Modifier = Modifier, - onPodcastUnfollowed: (String) -> Unit, - onCategorySelected: (HomeCategory) -> Unit -) { - Column(modifier = modifier) { - // We dynamically theme this sub-section of the layout to match the selected - // 'top podcast' - - val surfaceColor = MaterialTheme.colors.surface - val dominantColorState = rememberDominantColorState { color -> - // We want a color which has sufficient contrast against the surface color - color.constrastAgainst(surfaceColor) >= MinConstastOfPrimaryVsSurface - } - - DynamicThemePrimaryColorsFromImage(dominantColorState) { - val clock = AmbientAnimationClock.current - val pagerState = remember(clock) { PagerState(clock) } - - val selectedImageUrl = featuredPodcasts.getOrNull(pagerState.currentPage) - ?.podcast?.imageUrl - - // When the selected image url changes, call updateColorsFromImageUrl() or reset() - if (selectedImageUrl != null) { - LaunchedEffect(selectedImageUrl) { - dominantColorState.updateColorsFromImageUrl(selectedImageUrl) - } - } else { - dominantColorState.reset() - } - - Column( - modifier = Modifier.fillMaxWidth() - .verticalGradientScrim( - color = MaterialTheme.colors.primary.copy(alpha = 0.38f), - startYPercentage = 1f, - endYPercentage = 0f - ) - ) { - val appBarColor = MaterialTheme.colors.surface.copy(alpha = 0.87f) - - // Draw a scrim over the status bar which matches the app bar - Spacer(Modifier.background(appBarColor).fillMaxWidth().statusBarsHeight()) - - HomeAppBar( - backgroundColor = appBarColor, - modifier = Modifier.fillMaxWidth() - ) - - if (featuredPodcasts.isNotEmpty()) { - Spacer(Modifier.height(16.dp)) - - FollowedPodcasts( - items = featuredPodcasts, - pagerState = pagerState, - onPodcastUnfollowed = onPodcastUnfollowed, - modifier = Modifier - .padding(start = Keyline1, top = 16.dp, end = Keyline1) - .fillMaxWidth() - .preferredHeight(200.dp) - ) - - Spacer(Modifier.height(16.dp)) - } - } - } - - if (isRefreshing) { - // TODO show a progress indicator or similar - } - - if (homeCategories.isNotEmpty()) { - HomeCategoryTabs( - categories = homeCategories, - selectedCategory = selectedHomeCategory, - onCategorySelected = onCategorySelected - ) - } - - when (selectedHomeCategory) { - HomeCategory.Library -> { - // TODO - } - HomeCategory.Discover -> { - Discover(Modifier.fillMaxWidth().weight(1f)) - } - } - } -} - -@Composable -private fun HomeCategoryTabs( - categories: List<HomeCategory>, - selectedCategory: HomeCategory, - onCategorySelected: (HomeCategory) -> Unit, - modifier: Modifier = Modifier -) { - val selectedIndex = categories.indexOfFirst { it == selectedCategory } - val indicator = @Composable { tabPositions: List<TabPosition> -> - HomeCategoryTabIndicator( - Modifier.tabIndicatorOffset(tabPositions[selectedIndex]) - ) - } - - TabRow( - selectedTabIndex = selectedIndex, - indicator = indicator, - modifier = modifier - ) { - categories.forEachIndexed { index, category -> - Tab( - selected = index == selectedIndex, - onClick = { onCategorySelected(category) }, - text = { - Text( - text = when (category) { - HomeCategory.Library -> stringResource(R.string.home_library) - HomeCategory.Discover -> stringResource(R.string.home_discover) - }, - style = MaterialTheme.typography.body2 - ) - } - ) - } - } -} - -@Composable -fun HomeCategoryTabIndicator( - modifier: Modifier = Modifier, - color: Color = MaterialTheme.colors.onSurface -) { - Spacer( - modifier.padding(horizontal = 24.dp) - .preferredHeight(4.dp) - .background(color, RoundedCornerShape(topLeftPercent = 100, topRightPercent = 100)) - ) -} - -@Composable -fun FollowedPodcasts( - items: List<PodcastWithExtraInfo>, - modifier: Modifier = Modifier, - pagerState: PagerState = run { - val clock = AmbientAnimationClock.current - remember(clock) { PagerState(clock) } - }, - onPodcastUnfollowed: (String) -> Unit, -) { - pagerState.maxPage = (items.size - 1).coerceAtLeast(0) - - Pager( - state = pagerState, - modifier = modifier - ) { - val (podcast, lastEpisodeDate) = items[page] - FollowedPodcastCarouselItem( - podcastImageUrl = podcast.imageUrl, - lastEpisodeDate = lastEpisodeDate, - onUnfollowedClick = { onPodcastUnfollowed(podcast.uri) }, - modifier = Modifier.padding(4.dp).fillMaxHeight() - ) - } -} - -@Composable -private fun FollowedPodcastCarouselItem( - modifier: Modifier = Modifier, - podcastImageUrl: String? = null, - lastEpisodeDate: OffsetDateTime? = null, - onUnfollowedClick: () -> Unit, -) { - Column( - modifier.padding(horizontal = 12.dp, vertical = 8.dp) - ) { - Box( - Modifier - .weight(1f) - .align(Alignment.CenterHorizontally) - .aspectRatio(1f) - ) { - if (podcastImageUrl != null) { - CoilImage( - data = podcastImageUrl, - contentScale = ContentScale.Crop, - loading = { /* TODO do something better here */ }, - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.medium) - ) - } - - ToggleFollowPodcastIconButton( - onClick = onUnfollowedClick, - isFollowed = true, /* All podcasts are followed in this feed */ - modifier = Modifier.align(Alignment.BottomEnd) - ) - } - - if (lastEpisodeDate != null) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = lastUpdated(lastEpisodeDate), - style = MaterialTheme.typography.caption, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 8.dp) - .align(Alignment.CenterHorizontally) - ) - } - } - } -} - -@Composable -private fun lastUpdated(updated: OffsetDateTime): String { - val duration = Duration.between(updated.toLocalDateTime(), LocalDateTime.now()) - val days = duration.toDays().toInt() - - return when { - days > 28 -> stringResource(R.string.updated_longer) - days >= 7 -> { - val weeks = days / 7 - quantityStringResource(R.plurals.updated_weeks_ago, weeks, weeks) - } - days > 0 -> quantityStringResource(R.plurals.updated_days_ago, days, days) - else -> stringResource(R.string.updated_today) - } -} - -@Composable -@Preview -fun PreviewHomeContent() { - JetcasterTheme { - HomeContent( - featuredPodcasts = PreviewPodcastsWithExtraInfo, - isRefreshing = false, - homeCategories = HomeCategory.values().asList(), - selectedHomeCategory = HomeCategory.Discover, - onCategorySelected = {}, - onPodcastUnfollowed = {} - ) - } -} - -@Composable -@Preview -fun PreviewPodcastCard() { - JetcasterTheme { - FollowedPodcastCarouselItem( - modifier = Modifier.size(128.dp), - onUnfollowedClick = {} - ) - } -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt deleted file mode 100644 index d354685a4d..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.home - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.jetcaster.Graph -import com.example.jetcaster.data.PodcastStore -import com.example.jetcaster.data.PodcastWithExtraInfo -import com.example.jetcaster.data.PodcastsRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -class HomeViewModel( - private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, - private val podcastStore: PodcastStore = Graph.podcastStore -) : ViewModel() { - // Holds our currently selected home category - private val selectedCategory = MutableStateFlow(HomeCategory.Discover) - // Holds the currently available home categories - private val categories = MutableStateFlow(HomeCategory.values().asList()) - - // Holds our view state which the UI collects via [state] - private val _state = MutableStateFlow(HomeViewState()) - - private val refreshing = MutableStateFlow(false) - - val state: StateFlow<HomeViewState> - get() = _state - - init { - viewModelScope.launch { - // Combines the latest value from each of the flows, allowing us to generate a - // view state instance which only contains the latest values. - combine( - categories, - selectedCategory, - podcastStore.followedPodcastsSortedByLastEpisode(limit = 20), - refreshing - ) { categories, selectedCategory, podcasts, refreshing -> - HomeViewState( - homeCategories = categories, - selectedHomeCategory = selectedCategory, - featuredPodcasts = podcasts, - refreshing = refreshing, - errorMessage = null /* TODO */ - ) - }.catch { throwable -> - // TODO: emit a UI error here. For now we'll just rethrow - throw throwable - }.collect { - _state.value = it - } - } - - refresh(force = false) - } - - private fun refresh(force: Boolean) { - viewModelScope.launch { - runCatching { - refreshing.value = true - podcastsRepository.updatePodcasts(force) - } - // TODO: look at result of runCatching and show any errors - - refreshing.value = false - } - } - - fun onHomeCategorySelected(category: HomeCategory) { - selectedCategory.value = category - } - - fun onPodcastUnfollowed(podcastUri: String) { - viewModelScope.launch { - podcastStore.unfollowPodcast(podcastUri) - } - } -} - -enum class HomeCategory { - Library, Discover -} - -data class HomeViewState( - val featuredPodcasts: List<PodcastWithExtraInfo> = emptyList(), - val refreshing: Boolean = false, - val selectedHomeCategory: HomeCategory = HomeCategory.Discover, - val homeCategories: List<HomeCategory> = emptyList(), - val errorMessage: String? = null -) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt deleted file mode 100644 index 56df6a841e..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/PreviewData.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.home - -import com.example.jetcaster.data.Category -import com.example.jetcaster.data.Episode -import com.example.jetcaster.data.Podcast -import com.example.jetcaster.data.PodcastWithExtraInfo -import java.time.OffsetDateTime -import java.time.ZoneOffset - -val PreviewCategories = listOf( - Category(name = "Crime"), - Category(name = "News"), - Category(name = "Comedy") -) - -val PreviewPodcasts = listOf( - Podcast( - uri = "fakeUri://podcast/1", - title = "Android Developers Backstage", - author = "Android Developers" - ), - Podcast( - uri = "fakeUri://podcast/2", - title = "Google Developers podcast", - author = "Google Developers" - ) -) - -val PreviewPodcastsWithExtraInfo = PreviewPodcasts.mapIndexed { index, podcast -> - PodcastWithExtraInfo().apply { - this.podcast = podcast - this.lastEpisodeDate = OffsetDateTime.now() - this.isFollowed = index % 2 == 0 - } -} - -val PreviewEpisodes = listOf( - Episode( - uri = "fakeUri://episode/1", - podcastUri = PreviewPodcasts[0].uri, - title = "Episode 140: Bubbles!", - summary = "In this episode, Romain, Chet and Tor talked with Mady Melor and Artur Tsurkan from the System UI team about... Bubbles!", - published = OffsetDateTime.of(2020, 6, 2, 9, 27, 0, 0, ZoneOffset.of("PST")) - ) -) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt deleted file mode 100644 index 7855024834..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt +++ /dev/null @@ -1,358 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.home.category - -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ConstraintLayout -import androidx.compose.foundation.layout.Dimension -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PlaylistAdd -import androidx.compose.material.icons.rounded.PlayCircleFilled -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.viewModel -import com.example.jetcaster.R -import com.example.jetcaster.data.Episode -import com.example.jetcaster.data.Podcast -import com.example.jetcaster.data.PodcastWithExtraInfo -import com.example.jetcaster.ui.home.PreviewEpisodes -import com.example.jetcaster.ui.home.PreviewPodcasts -import com.example.jetcaster.ui.theme.JetcasterTheme -import com.example.jetcaster.ui.theme.Keyline1 -import com.example.jetcaster.util.ToggleFollowPodcastIconButton -import com.example.jetcaster.util.viewModelProviderFactoryOf -import dev.chrisbanes.accompanist.coil.CoilImage -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle - -@Composable -fun PodcastCategory( - categoryId: Long, - modifier: Modifier = Modifier -) { - /** - * CategoryEpisodeListViewModel requires the category as part of it's constructor, therefore - * we need to assist with it's instantiation with a custom factory and custom key. - */ - val viewModel: PodcastCategoryViewModel = viewModel( - // We use a custom key, using the category parameter - key = "category_list_$categoryId", - factory = viewModelProviderFactoryOf { PodcastCategoryViewModel(categoryId) } - ) - - val viewState by viewModel.state.collectAsState() - - /** - * TODO: reset scroll position when category changes - */ - LazyColumn( - modifier = modifier, - contentPadding = PaddingValues(0.dp), - horizontalAlignment = Alignment.Start - ) { - item { - CategoryPodcastRow( - podcasts = viewState.topPodcasts, - onTogglePodcastFollowed = viewModel::onTogglePodcastFollowed, - modifier = Modifier.fillParentMaxWidth() - ) - } - - items(viewState.episodes) { item -> - EpisodeListItem( - episode = item.episode, - podcast = item.podcast, - modifier = Modifier.fillParentMaxWidth() - ) - } - } -} - -@Composable -fun EpisodeListItem( - episode: Episode, - podcast: Podcast, - modifier: Modifier = Modifier -) { - ConstraintLayout( - modifier = Modifier.clickable { /* TODO */ } then modifier - ) { - val ( - divider, episodeTitle, podcastTitle, image, playIcon, - date, addPlaylist, overflow - ) = createRefs() - - Divider( - Modifier.constrainAs(divider) { - top.linkTo(parent.top) - centerHorizontallyTo(parent) - - width = Dimension.fillToConstraints - } - ) - - if (podcast.imageUrl != null) { - // If we have an image Url, we can show it using [CoilImage] - CoilImage( - data = podcast.imageUrl, - fadeIn = true, - contentScale = ContentScale.Crop, - loading = { /* TODO do something better here */ }, - modifier = Modifier.preferredSize(56.dp) - .clip(MaterialTheme.shapes.medium) - .constrainAs(image) { - end.linkTo(parent.end, 16.dp) - top.linkTo(parent.top, 16.dp) - } - ) - } else { - // If we don't have an image url, we need to make sure that the constraint reference - // still makes senses for our siblings. We add a zero sized spacer in the spacer - // origin position (top-end) with the same margin - Spacer( - Modifier.constrainAs(image) { - end.linkTo(parent.end, 16.dp) - top.linkTo(parent.top, 16.dp) - } - ) - } - - Text( - text = episode.title, - maxLines = 2, - style = MaterialTheme.typography.subtitle1, - modifier = Modifier.constrainAs(episodeTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f - ) - top.linkTo(parent.top, 16.dp) - - width = Dimension.preferredWrapContent - } - ) - - val titleImageBarrier = createBottomBarrier(podcastTitle, image) - - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = podcast.title, - maxLines = 2, - style = MaterialTheme.typography.subtitle2, - modifier = Modifier.constrainAs(podcastTitle) { - linkTo( - start = parent.start, - end = image.start, - startMargin = Keyline1, - endMargin = 16.dp, - bias = 0f - ) - top.linkTo(episodeTitle.bottom, 6.dp) - - width = Dimension.preferredWrapContent - } - ) - } - - Image( - imageVector = Icons.Rounded.PlayCircleFilled, - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(AmbientContentColor.current), - modifier = Modifier - .clickable(indication = rememberRipple(bounded = false, radius = 24.dp)) { - /* TODO */ - } - .preferredSize(36.dp) - .constrainAs(playIcon) { - start.linkTo(parent.start, Keyline1) - top.linkTo(titleImageBarrier, margin = 16.dp) - bottom.linkTo(parent.bottom, 16.dp) - } - ) - - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = when { - episode.duration != null -> { - // If we have the duration, we combine the date/duration via a - // formatted string - stringResource( - R.string.episode_date_duration, - MediumDateFormatter.format(episode.published), - episode.duration.toMinutes().toInt() - ) - } - // Otherwise we just use the date - else -> MediumDateFormatter.format(episode.published) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.caption, - modifier = Modifier.constrainAs(date) { - centerVerticallyTo(playIcon) - linkTo( - start = playIcon.end, - startMargin = 12.dp, - end = addPlaylist.start, - endMargin = 16.dp, - bias = 0f // float this towards the start - ) - } - ) - - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(addPlaylist) { - end.linkTo(overflow.start) - centerVerticallyTo(playIcon) - } - ) { - Icon(Icons.Default.PlaylistAdd) - } - - IconButton( - onClick = { /* TODO */ }, - modifier = Modifier.constrainAs(overflow) { - end.linkTo(parent.end, 8.dp) - centerVerticallyTo(playIcon) - } - ) { - Icon(Icons.Default.MoreVert) - } - } - } -} - -@Composable -private fun CategoryPodcastRow( - podcasts: List<PodcastWithExtraInfo>, - onTogglePodcastFollowed: (String) -> Unit, - modifier: Modifier = Modifier -) { - val lastIndex = podcasts.size - 1 - LazyRow( - modifier = modifier, - contentPadding = PaddingValues(start = Keyline1, top = 8.dp, end = Keyline1, bottom = 24.dp) - ) { - itemsIndexed(items = podcasts) { index: Int, (podcast, _, isFollowed): PodcastWithExtraInfo -> - TopPodcastRowItem( - podcastTitle = podcast.title, - podcastImageUrl = podcast.imageUrl, - isFollowed = isFollowed, - onToggleFollowClicked = { onTogglePodcastFollowed(podcast.uri) }, - modifier = Modifier.preferredWidth(128.dp) - ) - - if (index < lastIndex) Spacer(Modifier.preferredWidth(24.dp)) - } - } -} - -@Composable -private fun TopPodcastRowItem( - podcastTitle: String, - isFollowed: Boolean, - modifier: Modifier = Modifier, - onToggleFollowClicked: () -> Unit, - podcastImageUrl: String? = null, -) { - Column(modifier) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .align(Alignment.CenterHorizontally) - ) { - if (podcastImageUrl != null) { - CoilImage( - data = podcastImageUrl, - fadeIn = true, - contentScale = ContentScale.Crop, - loading = { /* TODO do something better here */ }, - modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.medium) - ) - } - - ToggleFollowPodcastIconButton( - onClick = onToggleFollowClicked, - isFollowed = isFollowed, - modifier = Modifier.align(Alignment.BottomEnd) - ) - } - - Text( - text = podcastTitle, - style = MaterialTheme.typography.body2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 8.dp).fillMaxWidth() - ) - } -} - -private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) -} - -@Preview -@Composable -fun PreviewEpisodeListItem() { - JetcasterTheme { - EpisodeListItem( - episode = PreviewEpisodes[0], - podcast = PreviewPodcasts[0], - modifier = Modifier.fillMaxWidth() - ) - } -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategoryViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategoryViewModel.kt deleted file mode 100644 index 6bac1984e8..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategoryViewModel.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.home.category - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.jetcaster.Graph -import com.example.jetcaster.data.CategoryStore -import com.example.jetcaster.data.EpisodeToPodcast -import com.example.jetcaster.data.PodcastStore -import com.example.jetcaster.data.PodcastWithExtraInfo -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -class PodcastCategoryViewModel( - private val categoryId: Long, - private val categoryStore: CategoryStore = Graph.categoryStore, - private val podcastStore: PodcastStore = Graph.podcastStore -) : ViewModel() { - private val _state = MutableStateFlow(PodcastCategoryViewState()) - - val state: StateFlow<PodcastCategoryViewState> - get() = _state - - init { - viewModelScope.launch { - val recentPodcastsFlow = categoryStore.podcastsInCategorySortedByPodcastCount( - categoryId, - limit = 10 - ) - - val episodesFlow = categoryStore.episodesFromPodcastsInCategory( - categoryId, - limit = 20 - ) - - // Combine our flows and collect them into the view state StateFlow - combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes -> - PodcastCategoryViewState( - topPodcasts = topPodcasts, - episodes = episodes - ) - }.collect { _state.value = it } - } - } - - fun onTogglePodcastFollowed(podcastUri: String) { - viewModelScope.launch { - podcastStore.togglePodcastFollowed(podcastUri) - } - } -} - -data class PodcastCategoryViewState( - val topPodcasts: List<PodcastWithExtraInfo> = emptyList(), - val episodes: List<EpisodeToPodcast> = emptyList() -) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt deleted file mode 100644 index 9ff89abf3d..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.home.discover - -import androidx.compose.animation.core.FloatPropKey -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.TransitionDefinition -import androidx.compose.animation.core.transitionDefinition -import androidx.compose.animation.core.tween -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.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ScrollableTabRow -import androidx.compose.material.Surface -import androidx.compose.material.Tab -import androidx.compose.material.TabPosition -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.emptyContent -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.onCommit -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.AmbientDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.viewModel -import com.example.jetcaster.data.Category -import com.example.jetcaster.ui.home.category.PodcastCategory -import com.example.jetcaster.ui.theme.Keyline1 -import com.example.jetcaster.util.ItemSwitcher -import com.example.jetcaster.util.ItemTransitionState - -@Composable -fun Discover( - modifier: Modifier = Modifier -) { - val viewModel: DiscoverViewModel = viewModel() - val viewState by viewModel.state.collectAsState() - - val selectedCategory = viewState.selectedCategory - - if (viewState.categories.isNotEmpty() && selectedCategory != null) { - Column(modifier) { - Spacer(Modifier.preferredHeight(8.dp)) - - // We need to keep track of the previously selected category, to determine the - // change direction below for the transition - var previousSelectedCategory by remember { mutableStateOf<Category?>(null) } - - PodcastCategoryTabs( - categories = viewState.categories, - selectedCategory = selectedCategory, - onCategorySelected = viewModel::onCategorySelected, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.preferredHeight(8.dp)) - - // We need to reverse the transition if the new category is to the left/start - // of the previous category - val reverseTransition = previousSelectedCategory?.let { p -> - viewState.categories.indexOf(selectedCategory) < viewState.categories.indexOf(p) - } ?: false - val transitionOffset = with(AmbientDensity.current) { 16.dp.toPx() } - - ItemSwitcher( - current = selectedCategory, - transitionDefinition = getChoiceChipTransitionDefinition( - reverse = reverseTransition, - offsetPx = transitionOffset - ), - modifier = Modifier.fillMaxWidth() - .weight(1f) - ) { category, transitionState -> - /** - * TODO, need to think about how this will scroll within the outer VerticalScroller - */ - PodcastCategory( - categoryId = category.id, - modifier = Modifier.fillMaxSize() - .graphicsLayer { - translationX = transitionState[Offset] - alpha = transitionState[Alpha] - } - ) - } - - onCommit(selectedCategory) { - // Update our tracking of the previously selected category - previousSelectedCategory = selectedCategory - } - } - } else { - // TODO: empty state - } -} - -private val emptyTabIndicator: @Composable (List<TabPosition>) -> Unit = {} - -@Composable -private fun PodcastCategoryTabs( - categories: List<Category>, - selectedCategory: Category, - onCategorySelected: (Category) -> Unit, - modifier: Modifier = Modifier -) { - val selectedIndex = categories.indexOfFirst { it == selectedCategory } - ScrollableTabRow( - selectedTabIndex = selectedIndex, - divider = emptyContent(), /* Disable the built-in divider */ - edgePadding = Keyline1, - indicator = emptyTabIndicator, - modifier = modifier - ) { - categories.forEachIndexed { index, category -> - Tab( - selected = index == selectedIndex, - onClick = { onCategorySelected(category) } - ) { - ChoiceChipContent( - text = category.name, - selected = index == selectedIndex, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp) - ) - } - } - } -} - -@Composable -private fun ChoiceChipContent( - text: String, - selected: Boolean, - modifier: Modifier = Modifier -) { - Surface( - color = when { - selected -> MaterialTheme.colors.primary.copy(alpha = 0.08f) - else -> MaterialTheme.colors.onSurface.copy(alpha = 0.12f) - }, - contentColor = when { - selected -> MaterialTheme.colors.primary - else -> MaterialTheme.colors.onSurface - }, - shape = MaterialTheme.shapes.small, - modifier = modifier - ) { - Text( - text = text, - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - } -} - -private val Alpha = FloatPropKey("alpha") -private val Offset = FloatPropKey("offset") - -@Composable -private fun getChoiceChipTransitionDefinition( - duration: Int = 183, - offsetPx: Float, - reverse: Boolean = false -): TransitionDefinition<ItemTransitionState> = remember(reverse, offsetPx, duration) { - transitionDefinition { - state(ItemTransitionState.Visible) { - this[Alpha] = 1f - this[Offset] = 0f - } - state(ItemTransitionState.BecomingVisible) { - this[Alpha] = 0f - this[Offset] = if (reverse) -offsetPx else offsetPx - } - state(ItemTransitionState.BecomingNotVisible) { - this[Alpha] = 0f - this[Offset] = if (reverse) offsetPx else -offsetPx - } - - val halfDuration = duration / 2 - - transition( - fromState = ItemTransitionState.BecomingVisible, - toState = ItemTransitionState.Visible - ) { - // TODO: look at whether this can be implemented using `spring` to enable - // interruptions, etc - Alpha using tween( - durationMillis = halfDuration, - delayMillis = halfDuration, - easing = LinearEasing - ) - Offset using tween( - durationMillis = halfDuration, - delayMillis = halfDuration, - easing = LinearOutSlowInEasing - ) - } - - transition( - fromState = ItemTransitionState.Visible, - toState = ItemTransitionState.BecomingNotVisible - ) { - Alpha using tween( - durationMillis = halfDuration, - easing = LinearEasing, - delayMillis = DelayForContentToLoad - ) - Offset using tween( - durationMillis = halfDuration, - easing = LinearOutSlowInEasing, - delayMillis = DelayForContentToLoad - ) - } - } -} - -/** - * This is a hack. Compose currently has no concept of delayed transitions, something akin to - * Fragment postponing of transitions while content loads. To workaround that for now, we - * introduce an initial hardcoded delay of 24ms. - */ -private const val DelayForContentToLoad = 24 diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/DiscoverViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/DiscoverViewModel.kt deleted file mode 100644 index d21d833927..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/discover/DiscoverViewModel.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.home.discover - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.jetcaster.Graph -import com.example.jetcaster.data.Category -import com.example.jetcaster.data.CategoryStore -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch - -class DiscoverViewModel( - private val categoryStore: CategoryStore = Graph.categoryStore -) : ViewModel() { - // Holds our currently selected category - private val _selectedCategory = MutableStateFlow<Category?>(null) - - // Holds our view state which the UI collects via [state] - private val _state = MutableStateFlow(DiscoverViewState()) - - val state: StateFlow<DiscoverViewState> - get() = _state - - init { - viewModelScope.launch { - // Combines the latest value from each of the flows, allowing us to generate a - // view state instance which only contains the latest values. - combine( - categoryStore.categoriesSortedByPodcastCount() - .onEach { categories -> - // If we haven't got a selected category yet, select the first - if (categories.isNotEmpty() && _selectedCategory.value == null) { - _selectedCategory.value = categories[0] - } - }, - _selectedCategory - ) { categories, selectedCategory -> - DiscoverViewState( - categories = categories, - selectedCategory = selectedCategory - ) - }.collect { _state.value = it } - } - } - - fun onCategorySelected(category: Category) { - _selectedCategory.value = category - } -} - -data class DiscoverViewState( - val categories: List<Category> = emptyList(), - val selectedCategory: Category? = null -) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt deleted file mode 100644 index 9c258e3108..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Color.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.theme - -import androidx.compose.material.Colors -import androidx.compose.material.darkColors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver - -/** - * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the - * given [alpha]. Useful for situations where semi-transparent colors are undesirable. - */ -@Composable -fun Colors.compositedOnSurface(alpha: Float): Color { - return onSurface.copy(alpha = alpha).compositeOver(surface) -} - -val Yellow800 = Color(0xFFF29F05) -val Red300 = Color(0xFFEA6D7E) - -val JetcasterColors = darkColors( - primary = Yellow800, - onPrimary = Color.Black, - primaryVariant = Yellow800, - secondary = Yellow800, - onSecondary = Color.Black, - error = Red300, - onError = Color.Black -) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt deleted file mode 100644 index 0e7b2e1148..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Shape.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -val JetcasterShapes = Shapes( - small = RoundedCornerShape(percent = 50), - medium = RoundedCornerShape(size = 8.dp), - large = RoundedCornerShape(size = 0.dp) -) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt deleted file mode 100644 index 477332fe46..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Theme.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.theme - -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable - -@Composable -fun JetcasterTheme( - content: @Composable () -> Unit -) { - MaterialTheme( - colors = JetcasterColors, - typography = JetcasterTypography, - shapes = JetcasterShapes, - content = content - ) -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt deleted file mode 100644 index 2f425dfb39..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Type.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.ui.theme - -import androidx.compose.material.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.font.font -import androidx.compose.ui.text.font.fontFamily -import androidx.compose.ui.unit.sp -import com.example.jetcaster.R - -private val Montserrat = fontFamily( - font(R.font.montserrat_light, FontWeight.Light), - font(R.font.montserrat_regular, FontWeight.Normal), - font(R.font.montserrat_medium, FontWeight.Medium), - font(R.font.montserrat_semibold, FontWeight.SemiBold) -) - -val JetcasterTypography = Typography( - h1 = TextStyle( - fontFamily = Montserrat, - fontSize = 96.sp, - fontWeight = FontWeight.Light, - lineHeight = 117.sp, - letterSpacing = (-1.5).sp - ), - h2 = TextStyle( - fontFamily = Montserrat, - fontSize = 60.sp, - fontWeight = FontWeight.Light, - lineHeight = 73.sp, - letterSpacing = (-0.5).sp - ), - h3 = TextStyle( - fontFamily = Montserrat, - fontSize = 48.sp, - fontWeight = FontWeight.Normal, - lineHeight = 59.sp - ), - h4 = TextStyle( - fontFamily = Montserrat, - fontSize = 30.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 37.sp - ), - h5 = TextStyle( - fontFamily = Montserrat, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 29.sp - ), - h6 = TextStyle( - fontFamily = Montserrat, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 24.sp - ), - subtitle1 = TextStyle( - fontFamily = Montserrat, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 20.sp, - letterSpacing = 0.5.sp - ), - subtitle2 = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - lineHeight = 17.sp, - letterSpacing = 0.1.sp - ), - body1 = TextStyle( - fontFamily = Montserrat, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - lineHeight = 20.sp, - letterSpacing = 0.15.sp - ), - body2 = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 20.sp, - letterSpacing = 0.25.sp - ), - button = TextStyle( - fontFamily = Montserrat, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 16.sp, - letterSpacing = 1.25.sp - ), - caption = TextStyle( - fontFamily = Montserrat, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 16.sp, - letterSpacing = 0.sp - ), - overline = TextStyle( - fontFamily = Montserrat, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - lineHeight = 16.sp, - letterSpacing = 1.sp - ) -) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt deleted file mode 100644 index 5f861d497d..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Buttons.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.util - -import androidx.compose.animation.animateAsState -import androidx.compose.animation.core.animateAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -@Composable -fun ToggleFollowPodcastIconButton( - isFollowed: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - IconButton( - onClick = onClick, - modifier = modifier - ) { - Icon( - // TODO: think about animating these icons - imageVector = when { - isFollowed -> Icons.Default.Check - else -> Icons.Default.Add - }, - tint = animateAsState( - when { - isFollowed -> AmbientContentColor.current - else -> Color.Black.copy(alpha = ContentAlpha.high) - } - ).value, - modifier = Modifier - .shadow( - elevation = animateAsState(if (isFollowed) 0.dp else 1.dp).value, - shape = MaterialTheme.shapes.small - ) - .background( - color = animateAsState( - when { - isFollowed -> MaterialTheme.colors.surface.copy(0.38f) - else -> Color.White - } - ).value, - shape = MaterialTheme.shapes.small - ) - .padding(4.dp) - ) - } -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Colors.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Colors.kt deleted file mode 100644 index 25a86a300b..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Colors.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.util - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.luminance -import kotlin.math.max -import kotlin.math.min - -fun Color.constrastAgainst(background: Color): Float { - val fg = if (alpha < 1f) compositeOver(background) else this - - val fgLuminance = fg.luminance() + 0.05f - val bgLuminance = background.luminance() + 0.05f - - return max(fgLuminance, bgLuminance) / min(fgLuminance, bgLuminance) -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt deleted file mode 100644 index ee5fc3a44d..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/DynamicTheming.kt +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.util - -import android.content.Context -import androidx.collection.LruCache -import androidx.compose.animation.animateAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.AmbientContext -import androidx.core.graphics.drawable.toBitmap -import androidx.palette.graphics.Palette -import coil.Coil -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.size.Scale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -@Composable -fun rememberDominantColorState( - context: Context = AmbientContext.current, - defaultColor: Color = MaterialTheme.colors.primary, - defaultOnColor: Color = MaterialTheme.colors.onPrimary, - cacheSize: Int = 12, - isColorValid: (Color) -> Boolean = { true } -): DominantColorState = remember { - DominantColorState(context, defaultColor, defaultOnColor, cacheSize, isColorValid) -} - -/** - * A composable which allows dynamic theming of the [androidx.compose.material.Colors.primary] - * color from an image. - */ -@Composable -fun DynamicThemePrimaryColorsFromImage( - dominantColorState: DominantColorState = rememberDominantColorState(), - content: @Composable () -> Unit -) { - val colors = MaterialTheme.colors.copy( - primary = animateAsState( - dominantColorState.color, - spring(stiffness = Spring.StiffnessLow) - ).value, - onPrimary = animateAsState( - dominantColorState.onColor, - spring(stiffness = Spring.StiffnessLow) - ).value - ) - MaterialTheme(colors = colors, content = content) -} - -/** - * A class which stores and caches the result of any calculated dominant colors - * from images. - * - * @param context Android context - * @param defaultColor The default color, which will be used if [calculateDominantColor] fails to - * calculate a dominant color - * @param defaultOnColor The default foreground 'on color' for [defaultColor]. - * @param cacheSize The size of the [LruCache] used to store recent results. Pass `0` to - * disable the cache. - * @param isColorValid A lambda which allows filtering of the calculated image colors. - */ -@Stable -class DominantColorState( - private val context: Context, - private val defaultColor: Color, - private val defaultOnColor: Color, - cacheSize: Int = 12, - private val isColorValid: (Color) -> Boolean = { true } -) { - var color by mutableStateOf(defaultColor) - private set - var onColor by mutableStateOf(defaultOnColor) - private set - - private val cache = when { - cacheSize > 0 -> LruCache<String, DominantColors>(cacheSize) - else -> null - } - - suspend fun updateColorsFromImageUrl(url: String) { - val result = calculateDominantColor(url) - color = result?.color ?: defaultColor - onColor = result?.onColor ?: defaultOnColor - } - - private suspend fun calculateDominantColor(url: String): DominantColors? { - val cached = cache?.get(url) - if (cached != null) { - // If we already have the result cached, return early now... - return cached - } - - // Otherwise we calculate the swatches in the image, and return the first valid color - return calculateSwatchesInImage(context, url) - // First we want to sort the list by the color's population - .sortedByDescending { swatch -> swatch.population } - // Then we want to find the first valid color - .firstOrNull { swatch -> isColorValid(Color(swatch.rgb)) } - // If we found a valid swatch, wrap it in a [DominantColors] - ?.let { swatch -> - DominantColors( - color = Color(swatch.rgb), - onColor = Color(swatch.bodyTextColor).copy(alpha = 1f) - ) - } - // Cache the resulting [DominantColors] - ?.also { result -> cache?.put(url, result) } - } - - /** - * Reset the color values to [defaultColor]. - */ - fun reset() { - color = defaultColor - onColor = defaultColor - } -} - -@Immutable -private data class DominantColors(val color: Color, val onColor: Color) - -/** - * Fetches the given [imageUrl] with [Coil], then uses [Palette] to calculate the dominant color. - */ -private suspend fun calculateSwatchesInImage( - context: Context, - imageUrl: String -): List<Palette.Swatch> { - val r = ImageRequest.Builder(context) - .data(imageUrl) - // We scale the image to cover 128px x 128px (i.e. min dimension == 128px) - .size(128).scale(Scale.FILL) - // Disable hardware bitmaps, since Palette uses Bitmap.getPixels() - .allowHardware(false) - .build() - - val bitmap = when (val result = Coil.execute(r)) { - is SuccessResult -> result.drawable.toBitmap() - else -> null - } - - return bitmap?.let { - withContext(Dispatchers.Default) { - val palette = Palette.Builder(bitmap) - // Disable any bitmap resizing in Palette. We've already loaded an appropriately - // sized bitmap through Coil - .resizeBitmapArea(0) - // Clear any built-in filters. We want the unfiltered dominant color - .clearFilters() - // We reduce the maximum color count down to 8 - .maximumColorCount(8) - .generate() - - palette.swatches - } - } ?: emptyList() -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt deleted file mode 100644 index 94b62682d1..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.util - -import androidx.annotation.FloatRange -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.composed -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import kotlin.math.pow - -/** - * Draws a vertical gradient scrim in the foreground. - * - * @param color The color of the gradient scrim. - * @param startYPercentage The start y value, in percentage of the layout's height (0f to 1f) - * @param endYPercentage The end y value, in percentage of the layout's height (0f to 1f) - * @param decay The exponential decay to apply to the gradient. Defaults to `1.0f` which is - * a linear gradient. - * @param numStops The number of color stops to draw in the gradient. Higher numbers result in - * the higher visual quality at the cost of draw performance. Defaults to `16`. - */ -fun Modifier.verticalGradientScrim( - color: Color, - @FloatRange(from = 0.0, to = 1.0) startYPercentage: Float = 0f, - @FloatRange(from = 0.0, to = 1.0) endYPercentage: Float = 1f, - decay: Float = 1.0f, - numStops: Int = 16 -): Modifier = composed { - val colors = remember(color, numStops) { - if (decay != 1f) { - // If we have a non-linear decay, we need to create the color gradient steps - // manually - val baseAlpha = color.alpha - List(numStops) { i -> - val x = i * 1f / (numStops - 1) - val opacity = x.pow(decay) - color.copy(alpha = baseAlpha * opacity) - } - } else { - // If we have a linear decay, we just create a simple list of start + end colors - listOf(color.copy(alpha = 0f), color) - } - } - - var height by remember { mutableStateOf(0f) } - val brush = remember(color, numStops, startYPercentage, endYPercentage, height) { - Brush.verticalGradient( - colors = colors, - startY = height * startYPercentage, - endY = height * endYPercentage - ) - } - - drawBehind { - height = size.height - drawRect(brush = brush) - } -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/ItemSwitcher.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/ItemSwitcher.kt deleted file mode 100644 index 9363247efe..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/ItemSwitcher.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.util - -import androidx.compose.animation.asDisposableClock -import androidx.compose.animation.core.TransitionDefinition -import androidx.compose.animation.core.TransitionState -import androidx.compose.animation.core.createAnimation -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.invalidate -import androidx.compose.runtime.key -import androidx.compose.runtime.onCommit -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.AmbientAnimationClock - -/** - * [ItemSwitcher] allows to switch between two layouts with a transition defined by - * [transitionDefinition]. - * - * @param current is a key representing your current layout state. Every time you change a key - * the animation will be triggered. The [content] called with the old key will be animated out while - * the [content] called with the new key will be animated in. - * @param transitionDefinition is a [TransitionDefinition] using [ItemTransitionState] as - * the state type. - * @param modifier Modifier to be applied to the animation container. - */ -@Composable -fun <T> ItemSwitcher( - current: T, - transitionDefinition: TransitionDefinition<ItemTransitionState>, - modifier: Modifier = Modifier, - content: @Composable (T, TransitionState) -> Unit -) { - val state = remember { ItemTransitionInnerState<T>() } - - if (current != state.current) { - state.current = current - val keys = state.items.map { it.key }.toMutableList() - if (!keys.contains(current)) { - keys.add(current) - } - state.items.clear() - - keys.mapTo(state.items) { key -> - ItemTransitionItem(key) { children -> - val clock = AmbientAnimationClock.current.asDisposableClock() - val visible = key == current - - val anim = remember(clock, transitionDefinition) { - transitionDefinition.createAnimation( - clock = clock, - initState = when { - visible -> ItemTransitionState.BecomingVisible - else -> ItemTransitionState.Visible - } - ) - } - - onCommit(visible) { - anim.onStateChangeFinished = { _ -> - if (key == state.current) { - // leave only the current in the list - state.items.removeAll { it.key != state.current } - state.invalidate() - } - } - anim.onUpdate = { state.invalidate() } - - val targetState = when { - visible -> ItemTransitionState.Visible - else -> ItemTransitionState.BecomingNotVisible - } - anim.toState(targetState) - } - - children(anim) - } - } - } - Box(modifier) { - state.invalidate = invalidate - state.items.forEach { (item, transition) -> - key(item) { - transition { transitionState -> - content(item, transitionState) - } - } - } - } -} - -enum class ItemTransitionState { - Visible, BecomingNotVisible, BecomingVisible, -} - -private class ItemTransitionInnerState<T> { - // we use Any here as something which will not be equals to the real initial value - var current: Any? = Any() - var items = mutableListOf<ItemTransitionItem<T>>() - var invalidate: () -> Unit = { } -} - -private data class ItemTransitionItem<T>( - val key: T, - val content: ItemTransitionContent -) - -private typealias ItemTransitionContent = @Composable (children: @Composable (TransitionState) -> Unit) -> Unit diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt deleted file mode 100644 index 60265535e6..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/Pager.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.util - -import androidx.compose.animation.AnimatedFloatModel -import androidx.compose.animation.core.AnimationClockObservable -import androidx.compose.animation.core.AnimationEndReason -import androidx.compose.animation.core.fling -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -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.runtime.structuralEqualityPolicy -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.gesture.scrollorientationlocking.Orientation -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.ParentDataModifier -import androidx.compose.ui.unit.Density -import kotlin.math.roundToInt - -/** - * This is a modified version of: - * https://gist.github.com/adamp/07d468f4bcfe632670f305ce3734f511 - */ - -class PagerState( - clock: AnimationClockObservable, - currentPage: Int = 0, - minPage: Int = 0, - maxPage: Int = 0 -) { - private var _minPage by mutableStateOf(minPage) - var minPage: Int - get() = _minPage - set(value) { - _minPage = value.coerceAtMost(_maxPage) - _currentPage = _currentPage.coerceIn(_minPage, _maxPage) - } - - private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy()) - var maxPage: Int - get() = _maxPage - set(value) { - _maxPage = value.coerceAtLeast(_minPage) - _currentPage = _currentPage.coerceIn(_minPage, maxPage) - } - - private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage)) - var currentPage: Int - get() = _currentPage - set(value) { - _currentPage = value.coerceIn(minPage, maxPage) - } - - enum class SelectionState { Selected, Undecided } - - var selectionState by mutableStateOf(SelectionState.Selected) - - inline fun <R> selectPage(block: PagerState.() -> R): R = try { - selectionState = SelectionState.Undecided - block() - } finally { - selectPage() - } - - fun selectPage() { - currentPage -= currentPageOffset.roundToInt() - currentPageOffset = 0f - selectionState = SelectionState.Selected - } - - private var _currentPageOffset = AnimatedFloatModel(0f, clock = clock).apply { - setBounds(-1f, 1f) - } - var currentPageOffset: Float - get() = _currentPageOffset.value - set(value) { - val max = if (currentPage == minPage) 0f else 1f - val min = if (currentPage == maxPage) 0f else -1f - _currentPageOffset.snapTo(value.coerceIn(min, max)) - } - - fun fling(velocity: Float) { - if (velocity < 0 && currentPage == maxPage) return - if (velocity > 0 && currentPage == minPage) return - - _currentPageOffset.fling(velocity) { reason, _, _ -> - if (reason != AnimationEndReason.Interrupted) { - _currentPageOffset.animateTo(currentPageOffset.roundToInt().toFloat()) { _, _ -> - selectPage() - } - } - } - } - - override fun toString(): String = "PagerState{minPage=$minPage, maxPage=$maxPage, " + - "currentPage=$currentPage, currentPageOffset=$currentPageOffset}" -} - -@Immutable -private data class PageData(val page: Int) : ParentDataModifier { - override fun Density.modifyParentData(parentData: Any?): Any = this@PageData -} - -private val Measurable.page: Int - get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this") - -@Composable -fun Pager( - state: PagerState, - modifier: Modifier = Modifier, - offscreenLimit: Int = 2, - pageContent: @Composable PagerScope.() -> Unit -) { - var pageSize by remember { mutableStateOf(0) } - Layout( - content = { - val minPage = (state.currentPage - offscreenLimit).coerceAtLeast(state.minPage) - val maxPage = (state.currentPage + offscreenLimit).coerceAtMost(state.maxPage) - - for (page in minPage..maxPage) { - val pageData = PageData(page) - val scope = PagerScope(state, page) - key(pageData) { - Box(contentAlignment = Alignment.Center, modifier = pageData) { - scope.pageContent() - } - } - } - }, - modifier = modifier.draggable( - orientation = Orientation.Horizontal, - onDragStarted = { - state.selectionState = PagerState.SelectionState.Undecided - }, - onDragStopped = { velocity -> - // Velocity is in pixels per second, but we deal in percentage offsets, so we - // need to scale the velocity to match - state.fling(velocity / pageSize) - } - ) { dy -> - with(state) { - val pos = pageSize * currentPageOffset - val max = if (currentPage == minPage) 0 else pageSize * offscreenLimit - val min = if (currentPage == maxPage) 0 else -pageSize * offscreenLimit - val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat()) - currentPageOffset = newPos / pageSize - } - } - ) { measurables, constraints -> - layout(constraints.maxWidth, constraints.maxHeight) { - val currentPage = state.currentPage - val offset = state.currentPageOffset - val childConstraints = constraints.copy(minWidth = 0, minHeight = 0) - - measurables - .map { - it.measure(childConstraints) to it.page - } - .forEach { (placeable, page) -> - // TODO: current this centers each page. We should investigate reading - // gravity modifiers on the child, or maybe as a param to Pager. - val xCenterOffset = (constraints.maxWidth - placeable.width) / 2 - val yCenterOffset = (constraints.maxHeight - placeable.height) / 2 - - if (currentPage == page) { - pageSize = placeable.width - } - - val xItemOffset = ((page + offset - currentPage) * placeable.width).roundToInt() - - placeable.place( - x = xCenterOffset + xItemOffset, - y = yCenterOffset - ) - } - } - } -} - -/** - * Scope for [Pager] content. - */ -class PagerScope( - private val state: PagerState, - val page: Int -) { - /** - * Returns the current selected page - */ - val currentPage: Int - get() = state.currentPage - - /** - * Returns the current selected page offset - */ - val currentPageOffset: Float - get() = state.currentPageOffset - - /** - * Returns the current selection state - */ - val selectionState: PagerState.SelectionState - get() = state.selectionState -} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/ViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/util/ViewModel.kt deleted file mode 100644 index 10f5498408..0000000000 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/ViewModel.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.util - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider - -/** - * Returns a [ViewModelProvider.Factory] which will return the result of [create] when it's - * [ViewModelProvider.Factory.create] function is called. - * - * If the created [ViewModel] does not match the requested class, an [IllegalArgumentException] - * exception is thrown. - */ -fun <VM : ViewModel> viewModelProviderFactoryOf( - create: () -> VM -): ViewModelProvider.Factory = SimpleFactory(create) - -/** - * This needs to be a named class currently to workaround a compiler issue: b/163807311 - */ -private class SimpleFactory<VM : ViewModel>( - private val create: () -> VM -) : ViewModelProvider.Factory { - override fun <T : ViewModel> create(modelClass: Class<T>): T { - val vm = create() - if (modelClass.isInstance(vm)) { - @Suppress("UNCHECKED_CAST") - return vm as T - } - throw IllegalArgumentException("Can not create ViewModel for class: $modelClass") - } -} diff --git a/Jetcaster/app/src/main/res/font/montserrat_semibold.ttf b/Jetcaster/app/src/main/res/font/montserrat_semibold.ttf deleted file mode 100755 index f8a43f2b20..0000000000 Binary files a/Jetcaster/app/src/main/res/font/montserrat_semibold.ttf and /dev/null differ diff --git a/Jetcaster/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetcaster/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 3e3ac4da85..0000000000 --- a/Jetcaster/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,21 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - --> - -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@color/ic_launcher_background"/> - <foreground android:drawable="@drawable/ic_launcher_foreground"/> -</adaptive-icon> \ No newline at end of file diff --git a/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index f5bc5c0286..0000000000 Binary files a/Jetcaster/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml deleted file mode 100644 index fe66f85a95..0000000000 --- a/Jetcaster/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,43 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - --> -<resources> - <string name="app_name">Jetcaster</string> - - <string name="connection_error_title">Connection error</string> - <string name="connection_error_message">Unable to fetch podcasts feeds.\nCheck your internet connection and try again.</string> - <string name="retry_label">Retry</string> - - <string name="your_podcasts">Your podcasts</string> - <string name="latest_episodes">Latest episodes</string> - - <string name="home_library">Your library</string> - <string name="home_discover">Discover</string> - - <string name="updated_longer">Updated a while ago</string> - <plurals name="updated_weeks_ago"> - <item quantity="one">Updated %d week ago</item> - <item quantity="other">Updated %d weeks ago</item> - </plurals> - <plurals name="updated_days_ago"> - <item quantity="one">Updated yesterday</item> - <item quantity="other">Updated %d days ago</item> - </plurals> - <string name="updated_today">Updated today</string> - - <string name="episode_date_duration">%1$s • %2$d mins</string> - -</resources> diff --git a/Jetcaster/app/src/main/res/values/themes.xml b/Jetcaster/app/src/main/res/values/themes.xml deleted file mode 100644 index 7548467663..0000000000 --- a/Jetcaster/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - --> -<resources> - - <style name="Theme.Jetcaster" parent="android:Theme.Material.NoActionBar"> - <item name="android:colorPrimary">#ff00ff</item> - <item name="android:colorAccent">#ff00ff</item> - <item name="android:statusBarColor">@android:color/transparent</item> - <item name="android:navigationBarColor">@android:color/transparent</item> - </style> - -</resources> diff --git a/Jetcaster/build.gradle b/Jetcaster/build.gradle deleted file mode 100644 index 08cef649e9..0000000000 --- a/Jetcaster/build.gradle +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.jetcaster.buildsrc.Libs -import com.example.jetcaster.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.7.0' -} - -subprojects { - repositories { - google() - mavenCentral() - jcenter() - - // Jetpack Compose SNAPSHOTs - if (Libs.AndroidX.Compose.version.endsWith("SNAPSHOT")) { - maven { url Libs.AndroidX.Compose.snapshotUrl } - } - - maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - - ktlint(Versions.ktlint) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - - // Enable experimental coroutines APIs, including Flow - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' - freeCompilerArgs += '-Xopt-in=kotlin.Experimental' - - freeCompilerArgs += "-Xallow-jvm-ir-dependencies" - - // Set JVM target to 1.8 - jvmTarget = "1.8" - } - } -} diff --git a/Jetcaster/build.gradle.kts b/Jetcaster/build.gradle.kts new file mode 100644 index 0000000000..bcd5617e23 --- /dev/null +++ b/Jetcaster/build.gradle.kts @@ -0,0 +1,76 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.gradle.versions) + alias(libs.plugins.version.catalog.update) + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.compose) apply false + alias(libs.plugins.spotless) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure<com.diffplug.gradle.spotless.SpotlessExtension> { + ratchetFrom = "origin/main" + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt") + ktlint().editorConfigOverride( + mapOf( + "ktlint_code_style" to "android_studio", + "ij_kotlin_allow_trailing_comma" to true, + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + // 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"), "(^(?![\\/ ]\\*).*$)") + } + kotlinGradle { + target("*.gradle.kts") + ktlint() + } + } +} diff --git a/Jetcaster/buildSrc/build.gradle.kts b/Jetcaster/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Jetcaster/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Jetcaster/buildSrc/src/main/java/com/example/jetcaster/buildsrc/dependencies.kt b/Jetcaster/buildSrc/src/main/java/com/example/jetcaster/buildsrc/dependencies.kt deleted file mode 100644 index e84f7b2568..0000000000 --- a/Jetcaster/buildSrc/src/main/java/com/example/jetcaster/buildsrc/dependencies.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetcaster.buildsrc - -object Versions { - const val ktlint = "0.39.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha04" - const val jdkDesugar = "com.android.tools:desugar_jdk_libs:1.0.9" - - const val junit = "junit:junit:4.13" - - const val material = "com.google.android.material:material:1.1.0" - - object Accompanist { - private const val version = "0.4.2" - const val coil = "dev.chrisbanes.accompanist:accompanist-coil:$version" - const val insets = "dev.chrisbanes.accompanist:accompanist-insets:$version" - } - - object Kotlin { - private const val version = "1.4.21" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.2" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object OkHttp { - private const val version = "4.9.0" - const val okhttp = "com.squareup.okhttp3:okhttp:$version" - const val logging = "com.squareup.okhttp3:logging-interceptor:$version" - } - - object AndroidX { - const val appcompat = "androidx.appcompat:appcompat:1.2.0" - const val palette = "androidx.palette:palette:1.0.0" - - const val coreKtx = "androidx.core:core-ktx:1.5.0-beta01" - - object Compose { - private const val snapshot = "" - private const val version = "1.0.0-alpha10" - - @get:JvmStatic - val snapshotUrl: String - get() = "https://androidx.dev/snapshots/builds/$snapshot/artifacts/repository/" - - const val runtime = "androidx.compose.runtime:runtime:$version" - const val foundation = "androidx.compose.foundation:foundation:${version}" - const val layout = "androidx.compose.foundation:foundation-layout:${version}" - - const val ui = "androidx.compose.ui:ui:${version}" - const val material = "androidx.compose.material:material:${version}" - const val materialIconsExtended = "androidx.compose.material:material-icons-extended:${version}" - - const val tooling = "androidx.compose.ui:ui-tooling:${version}" - } - - object Test { - private const val version = "1.2.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - - object Room { - private const val version = "2.2.5" - const val runtime = "androidx.room:room-runtime:${version}" - const val ktx = "androidx.room:room-ktx:${version}" - const val compiler = "androidx.room:room-compiler:${version}" - } - - object Lifecycle { - private const val version = "2.2.0" - const val extensions = "androidx.lifecycle:lifecycle-extensions:$version" - const val livedata = "androidx.lifecycle:lifecycle-livedata-ktx:$version" - const val viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - } - } - - object Rome { - private const val version = "1.14.1" - const val rome = "com.rometools:rome:$version" - const val modules = "com.rometools:rome-modules:$version" - } -} diff --git a/Jetcaster/buildscripts/toml-updater-config.gradle b/Jetcaster/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/Jetcaster/buildscripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/Rally/app/.gitignore b/Jetcaster/core/.gitignore similarity index 100% rename from Rally/app/.gitignore rename to Jetcaster/core/.gitignore diff --git a/Jetcaster/core/data-testing/.gitignore b/Jetcaster/core/data-testing/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/data-testing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/data-testing/build.gradle.kts b/Jetcaster/core/data-testing/build.gradle.kts new file mode 100644 index 0000000000..a8644e1c1b --- /dev/null +++ b/Jetcaster/core/data-testing/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.example.jetcaster.core.data.testing" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} +kotlin { + jvmToolchain(17) +} +dependencies { + implementation(libs.androidx.core.ktx) + implementation(projects.core.data) + coreLibraryDesugaring(libs.core.jdk.desugaring) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/Jetcaster/core/data-testing/consumer-rules.pro b/Jetcaster/core/data-testing/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/data-testing/proguard-rules.pro b/Jetcaster/core/data-testing/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/data-testing/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/data-testing/src/main/AndroidManifest.xml b/Jetcaster/core/data-testing/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/data-testing/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + +</manifest> diff --git a/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestCategoryStore.kt b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestCategoryStore.kt new file mode 100644 index 0000000000..60de97944d --- /dev/null +++ b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestCategoryStore.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.testing.repository + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.repository.CategoryStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** + * A [CategoryStore] used for testing. + * + * // TODO: Move to :testing module upon merging PR #1379 + */ +class TestCategoryStore : CategoryStore { + + private val categoryFlow = MutableStateFlow<List<Category>>(emptyList()) + private val podcastsInCategoryFlow = + MutableStateFlow<Map<Long, List<PodcastWithExtraInfo>>>(emptyMap()) + private val episodesFromPodcasts = + MutableStateFlow<Map<Long, List<EpisodeToPodcast>>>(emptyMap()) + + override fun categoriesSortedByPodcastCount(limit: Int): Flow<List<Category>> = + categoryFlow + + override fun podcastsInCategorySortedByPodcastCount( + categoryId: Long, + limit: Int + ): Flow<List<PodcastWithExtraInfo>> = podcastsInCategoryFlow.map { + it[categoryId]?.take(limit) ?: emptyList() + } + + override fun episodesFromPodcastsInCategory( + categoryId: Long, + limit: Int + ): Flow<List<EpisodeToPodcast>> = episodesFromPodcasts.map { + it[categoryId]?.take(limit) ?: emptyList() + } + + override suspend fun addCategory(category: Category): Long = -1 + + override suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) {} + + override fun getCategory(name: String): Flow<Category?> = flowOf() + + /** + * Test-only API for setting the list of categories backed by this [TestCategoryStore]. + */ + fun setCategories(categories: List<Category>) { + categoryFlow.value = categories + } + + /** + * Test-only API for setting the list of podcasts in a category backed by this + * [TestCategoryStore]. + */ + fun setPodcastsInCategory(categoryId: Long, podcastsInCategory: List<PodcastWithExtraInfo>) { + podcastsInCategoryFlow.update { + it + Pair(categoryId, podcastsInCategory) + } + } + + /** + * Test-only API for setting the list of podcasts in a category backed by this + * [TestCategoryStore]. + */ + fun setEpisodesFromPodcast(categoryId: Long, podcastsInCategory: List<EpisodeToPodcast>) { + episodesFromPodcasts.update { + it + Pair(categoryId, podcastsInCategory) + } + } +} diff --git a/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestEpisodeStore.kt b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestEpisodeStore.kt new file mode 100644 index 0000000000..9dd7c526ea --- /dev/null +++ b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestEpisodeStore.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.testing.repository + +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.repository.EpisodeStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +// TODO: Move to :testing module upon merging PR #1379 +class TestEpisodeStore : EpisodeStore { + + private val episodesFlow = MutableStateFlow<List<Episode>>(listOf()) + override fun episodeWithUri(episodeUri: String): Flow<Episode> = + episodesFlow.map { episodes -> + episodes.first { it.uri == episodeUri } + } + + override fun episodeAndPodcastWithUri(episodeUri: String): Flow<EpisodeToPodcast> = + episodesFlow.map { episodes -> + val e = episodes.first { + it.uri == episodeUri + } + EpisodeToPodcast().apply { + episode = e + _podcasts = emptyList() + } + } + + override fun episodesInPodcast(podcastUri: String, limit: Int): Flow<List<EpisodeToPodcast>> = + episodesFlow.map { episodes -> + episodes.filter { + it.podcastUri == podcastUri + }.map { e -> + EpisodeToPodcast().apply { + episode = e + } + } + } + + override fun episodesInPodcasts( + podcastUris: List<String>, + limit: Int + ): Flow<List<EpisodeToPodcast>> = + episodesFlow.map { episodes -> + episodes.filter { + podcastUris.contains(it.podcastUri) + }.map { ep -> + EpisodeToPodcast().apply { + episode = ep + } + } + } + + override suspend fun addEpisodes(episodes: Collection<Episode>) = + episodesFlow.update { + it + episodes + } + + override suspend fun isEmpty(): Boolean = + episodesFlow.first().isEmpty() +} diff --git a/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestPodcastStore.kt b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestPodcastStore.kt new file mode 100644 index 0000000000..8a4808ecfd --- /dev/null +++ b/Jetcaster/core/data-testing/src/main/java/com/example/jetcaster/core/data/testing/repository/TestPodcastStore.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.testing.repository + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.repository.PodcastStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +// TODO: Move to :testing module upon merging PR #1379 +class TestPodcastStore : PodcastStore { + + private val podcastFlow = MutableStateFlow<List<Podcast>>(listOf()) + private val followedPodcasts = mutableSetOf<String>() + override fun podcastWithUri(uri: String): Flow<Podcast> = + podcastFlow.map { podcasts -> + podcasts.first { it.uri == uri } + } + + override fun podcastWithExtraInfo(podcastUri: String): Flow<PodcastWithExtraInfo> = + podcastFlow.map { podcasts -> + val podcast = podcasts.first { it.uri == podcastUri } + PodcastWithExtraInfo().apply { + this.podcast = podcast + } + } + + override fun podcastsSortedByLastEpisode(limit: Int): Flow<List<PodcastWithExtraInfo>> = + podcastFlow.map { podcasts -> + podcasts.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = followedPodcasts.contains(p.uri) + } + } + } + + override fun followedPodcastsSortedByLastEpisode(limit: Int): Flow<List<PodcastWithExtraInfo>> = + podcastFlow.map { podcasts -> + podcasts.filter { + followedPodcasts.contains(it.uri) + }.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = true + } + } + } + + override fun searchPodcastByTitle( + keyword: String, + limit: Int + ): Flow<List<PodcastWithExtraInfo>> = + podcastFlow.map { podcastList -> + podcastList.filter { + it.title.contains(keyword) + }.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = true + } + } + } + + override fun searchPodcastByTitleAndCategories( + keyword: String, + categories: List<Category>, + limit: Int + ): Flow<List<PodcastWithExtraInfo>> = + podcastFlow.map { podcastList -> + podcastList.filter { + it.title.contains(keyword) + }.map { p -> + PodcastWithExtraInfo().apply { + podcast = p + isFollowed = true + } + } + } + + override suspend fun togglePodcastFollowed(podcastUri: String) { + if (podcastUri in followedPodcasts) { + unfollowPodcast(podcastUri) + } else { + followPodcast(podcastUri) + } + } + + override suspend fun followPodcast(podcastUri: String) { + followedPodcasts.add(podcastUri) + } + + override suspend fun unfollowPodcast(podcastUri: String) { + followedPodcasts.remove(podcastUri) + } + + override suspend fun addPodcast(podcast: Podcast) = + podcastFlow.update { it + podcast } + + override suspend fun isEmpty(): Boolean = + podcastFlow.first().isEmpty() +} diff --git a/Jetcaster/core/data/.gitignore b/Jetcaster/core/data/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/data/build.gradle.kts b/Jetcaster/core/data/build.gradle.kts new file mode 100644 index 0000000000..bd81f036e7 --- /dev/null +++ b/Jetcaster/core/data/build.gradle.kts @@ -0,0 +1,72 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.example.jetcaster.core.data" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildFeatures { + buildConfig = true + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} +kotlin { + jvmToolchain(17) +} +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.compose.runtime) + + // Image loading + implementation(libs.coil.kt.compose) + + // Compose + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Networking + implementation(libs.okhttp3) + implementation(libs.okhttp.logging) + + // Database + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + implementation(libs.rometools.rome) + implementation(libs.rometools.modules) + + coreLibraryDesugaring(libs.core.jdk.desugaring) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/Jetcaster/core/data/consumer-rules.pro b/Jetcaster/core/data/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/data/proguard-rules.pro b/Jetcaster/core/data/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/data/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/data/src/main/AndroidManifest.xml b/Jetcaster/core/data/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/data/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + +</manifest> diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt new file mode 100644 index 0000000000..a57199979c --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/Dispatcher.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class Dispatcher(val jetcasterDispatcher: JetcasterDispatchers) + +enum class JetcasterDispatchers { + Main, + IO, +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt similarity index 97% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt index 4b4fb5d0a9..0199678c4c 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/DateTimeTypeConverters.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/DateTimeTypeConverters.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database import androidx.room.TypeConverter import java.time.Duration diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt new file mode 100644 index 0000000000..ced5d408b0 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/JetcasterDatabase.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.example.jetcaster.core.data.database.dao.CategoriesDao +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.dao.TransactionRunnerDao +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry +import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry + +/** + * The [RoomDatabase] we use in this app. + */ +@Database( + entities = [ + Podcast::class, + Episode::class, + PodcastCategoryEntry::class, + Category::class, + PodcastFollowedEntry::class + ], + version = 1, + exportSchema = false +) +@TypeConverters(DateTimeTypeConverters::class) +abstract class JetcasterDatabase : RoomDatabase() { + abstract fun podcastsDao(): PodcastsDao + abstract fun episodesDao(): EpisodesDao + abstract fun categoriesDao(): CategoriesDao + abstract fun podcastCategoryEntryDao(): PodcastCategoryEntryDao + abstract fun transactionRunnerDao(): TransactionRunnerDao + abstract fun podcastFollowedEntryDao(): PodcastFollowedEntryDao +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt new file mode 100644 index 0000000000..eca987c370 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/BaseDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Update + +/** + * Base DAO. + */ +interface BaseDao<T> { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: T): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(vararg entity: T) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: Collection<T>) + + @Update(onConflict = OnConflictStrategy.REPLACE) + suspend fun update(entity: T) + + @Delete + suspend fun delete(entity: T): Int +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt new file mode 100644 index 0000000000..baf958f139 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/CategoriesDao.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import com.example.jetcaster.core.data.database.model.Category +import kotlinx.coroutines.flow.Flow + +/** + * [Room] DAO for [Category] related operations. + */ +@Dao +abstract class CategoriesDao : BaseDao<Category> { + @Query( + """ + SELECT categories.* FROM categories + INNER JOIN ( + SELECT category_id, COUNT(podcast_uri) AS podcast_count FROM podcast_category_entries + GROUP BY category_id + ) ON category_id = categories.id + ORDER BY podcast_count DESC + LIMIT :limit + """ + ) + abstract fun categoriesSortedByPodcastCount( + limit: Int + ): Flow<List<Category>> + + @Query("SELECT * FROM categories WHERE name = :name") + abstract suspend fun getCategoryWithName(name: String): Category? + + @Query("SELECT * FROM categories WHERE name = :name") + abstract fun observeCategory(name: String): Flow<Category?> +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt new file mode 100644 index 0000000000..e1d60d5f07 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import kotlinx.coroutines.flow.Flow + +/** + * [Room] DAO for [Episode] related operations. + */ +@Dao +abstract class EpisodesDao : BaseDao<Episode> { + + @Query( + """ + SELECT * FROM episodes WHERE uri = :uri + """ + ) + abstract fun episode(uri: String): Flow<Episode> + + @Transaction + @Query( + """ + SELECT episodes.* FROM episodes + INNER JOIN podcasts ON episodes.podcast_uri = podcasts.uri + WHERE episodes.uri = :episodeUri + """ + ) + abstract fun episodeAndPodcast(episodeUri: String): Flow<EpisodeToPodcast> + + @Transaction + @Query( + """ + SELECT * FROM episodes WHERE podcast_uri = :podcastUri + ORDER BY datetime(published) DESC + LIMIT :limit + """ + ) + abstract fun episodesForPodcastUri( + podcastUri: String, + limit: Int + ): Flow<List<EpisodeToPodcast>> + + @Transaction + @Query( + """ + SELECT episodes.* FROM episodes + INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri + WHERE category_id = :categoryId + ORDER BY datetime(published) DESC + LIMIT :limit + """ + ) + abstract fun episodesFromPodcastsInCategory( + categoryId: Long, + limit: Int + ): Flow<List<EpisodeToPodcast>> + + @Query("SELECT COUNT(*) FROM episodes") + abstract suspend fun count(): Int + + @Transaction + @Query( + """ + SELECT * FROM episodes WHERE podcast_uri IN (:podcastUris) + ORDER BY datetime(published) DESC + LIMIT :limit + """ + ) + abstract fun episodesForPodcasts( + podcastUris: List<String>, + limit: Int + ): Flow<List<EpisodeToPodcast>> +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt new file mode 100644 index 0000000000..5291649e34 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastCategoryEntryDao.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry + +/** + * [Room] DAO for [PodcastCategoryEntry] related operations. + */ +@Dao +abstract class PodcastCategoryEntryDao : BaseDao<PodcastCategoryEntry> diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt new file mode 100644 index 0000000000..0816cc05e7 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastFollowedEntryDao.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry + +@Dao +abstract class PodcastFollowedEntryDao : BaseDao<PodcastFollowedEntry> { + @Query("DELETE FROM podcast_followed_entries WHERE podcast_uri = :podcastUri") + abstract suspend fun deleteWithPodcastUri(podcastUri: String) + + @Query("SELECT COUNT(*) FROM podcast_followed_entries WHERE podcast_uri = :podcastUri") + protected abstract suspend fun podcastFollowRowCount(podcastUri: String): Int + + suspend fun isPodcastFollowed(podcastUri: String): Boolean { + return podcastFollowRowCount(podcastUri) > 0 + } +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt new file mode 100644 index 0000000000..4d5ce71755 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/PodcastsDao.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import kotlinx.coroutines.flow.Flow + +/** + * [Room] DAO for [Podcast] related operations. + */ +@Dao +abstract class PodcastsDao : BaseDao<Podcast> { + @Query("SELECT * FROM podcasts WHERE uri = :uri") + abstract fun podcastWithUri(uri: String): Flow<Podcast> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT podcast_uri, MAX(published) AS last_episode_date + FROM episodes + GROUP BY podcast_uri + ) episodes ON podcasts.uri = episodes.podcast_uri + LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = podcasts.uri + WHERE podcasts.uri = :podcastUri + ORDER BY datetime(last_episode_date) DESC + """ + ) + abstract fun podcastWithExtraInfo(podcastUri: String): Flow<PodcastWithExtraInfo> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT podcast_uri, MAX(published) AS last_episode_date + FROM episodes + GROUP BY podcast_uri + ) episodes ON podcasts.uri = episodes.podcast_uri + LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """ + ) + abstract fun podcastsSortedByLastEpisode( + limit: Int + ): Flow<List<PodcastWithExtraInfo>> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT episodes.podcast_uri, MAX(published) AS last_episode_date + FROM episodes + INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri + WHERE category_id = :categoryId + GROUP BY episodes.podcast_uri + ) inner_query ON podcasts.uri = inner_query.podcast_uri + LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = inner_query.podcast_uri + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """ + ) + abstract fun podcastsInCategorySortedByLastEpisode( + categoryId: Long, + limit: Int + ): Flow<List<PodcastWithExtraInfo>> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT podcast_uri, MAX(published) AS last_episode_date FROM episodes GROUP BY podcast_uri + ) episodes ON podcasts.uri = episodes.podcast_uri + INNER JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """ + ) + abstract fun followedPodcastsSortedByLastEpisode( + limit: Int + ): Flow<List<PodcastWithExtraInfo>> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT podcast_uri, MAX(published) AS last_episode_date FROM episodes GROUP BY podcast_uri + ) episodes ON podcasts.uri = episodes.podcast_uri + INNER JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = episodes.podcast_uri + WHERE podcasts.title LIKE '%' || :keyword || '%' + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """ + ) + abstract fun searchPodcastByTitle(keyword: String, limit: Int): Flow<List<PodcastWithExtraInfo>> + + @Transaction + @Query( + """ + SELECT podcasts.*, last_episode_date, (followed_entries.podcast_uri IS NOT NULL) AS is_followed + FROM podcasts + INNER JOIN ( + SELECT episodes.podcast_uri, MAX(published) AS last_episode_date + FROM episodes + INNER JOIN podcast_category_entries ON episodes.podcast_uri = podcast_category_entries.podcast_uri + WHERE category_id IN (:categoryIdList) + GROUP BY episodes.podcast_uri + ) inner_query ON podcasts.uri = inner_query.podcast_uri + LEFT JOIN podcast_followed_entries AS followed_entries ON followed_entries.podcast_uri = inner_query.podcast_uri + WHERE podcasts.title LIKE '%' || :keyword || '%' + ORDER BY datetime(last_episode_date) DESC + LIMIT :limit + """ + ) + abstract fun searchPodcastByTitleAndCategory( + keyword: String, + categoryIdList: List<Long>, + limit: Int + ): Flow<List<PodcastWithExtraInfo>> + + @Query("SELECT COUNT(*) FROM podcasts") + abstract suspend fun count(): Int +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt similarity index 95% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt index e7c51cad4f..6f4b0c49e6 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/room/TransactionRunnerDao.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/dao/TransactionRunnerDao.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data.room +package com.example.jetcaster.core.data.database.dao import androidx.room.Dao import androidx.room.Ignore diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt similarity index 94% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt index 3279017b3a..4dff2871ef 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Category.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Category.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Episode.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt similarity index 97% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Episode.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt index b5dc88b94d..6a035d9646 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Episode.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Episode.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt similarity index 96% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt index 4f87ba9e05..7945f20316 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/EpisodeToPodcast.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/EpisodeToPodcast.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.room.Embedded import androidx.room.Ignore diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt similarity index 95% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt index 969908f14a..1d86f31f91 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Podcast.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/Podcast.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt similarity index 96% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt index 394af2fca8..3c2c67878d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastCategoryEntry.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastCategoryEntry.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt similarity index 96% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt index 0be51c77bc..420e68f38f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastFollowedEntry.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastFollowedEntry.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt similarity index 96% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt index 200e6248c2..8794a46e47 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/PodcastWithExtraInfo.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/database/model/PodcastWithExtraInfo.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.database.model import androidx.room.ColumnInfo import androidx.room.Embedded diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/di/DataDiModule.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/di/DataDiModule.kt new file mode 100644 index 0000000000..68d4b1920f --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/di/DataDiModule.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.di + +import android.content.Context +import androidx.room.Room +import coil.ImageLoader +import com.example.jetcaster.core.data.BuildConfig +import com.example.jetcaster.core.data.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.data.database.JetcasterDatabase +import com.example.jetcaster.core.data.database.dao.CategoriesDao +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.dao.TransactionRunner +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.LocalCategoryStore +import com.example.jetcaster.core.data.repository.LocalEpisodeStore +import com.example.jetcaster.core.data.repository.LocalPodcastStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.rometools.rome.io.SyndFeedInput +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.io.File +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.logging.LoggingEventListener + +@Module +@InstallIn(SingletonComponent::class) +object DataDiModule { + + @Provides + @Singleton + fun provideOkHttpClient( + @ApplicationContext context: Context + ): OkHttpClient = OkHttpClient.Builder() + .cache(Cache(File(context.cacheDir, "http_cache"), (20 * 1024 * 1024).toLong())) + .apply { + if (BuildConfig.DEBUG) eventListenerFactory(LoggingEventListener.Factory()) + } + .build() + + @Provides + @Singleton + fun provideDatabase( + @ApplicationContext context: Context + ): JetcasterDatabase = + Room.databaseBuilder(context, JetcasterDatabase::class.java, "data.db") + // This is not recommended for normal apps, but the goal of this sample isn't to + // showcase all of Room. + .fallbackToDestructiveMigration() + .build() + + @Provides + @Singleton + fun provideImageLoader( + @ApplicationContext context: Context + ): ImageLoader = ImageLoader.Builder(context) + // Disable `Cache-Control` header support as some podcast images disable disk caching. + .respectCacheHeaders(false) + .build() + + @Provides + @Singleton + fun provideCategoriesDao( + database: JetcasterDatabase + ): CategoriesDao = database.categoriesDao() + + @Provides + @Singleton + fun providePodcastCategoryEntryDao( + database: JetcasterDatabase + ): PodcastCategoryEntryDao = database.podcastCategoryEntryDao() + + @Provides + @Singleton + fun providePodcastsDao( + database: JetcasterDatabase + ): PodcastsDao = database.podcastsDao() + + @Provides + @Singleton + fun provideEpisodesDao( + database: JetcasterDatabase + ): EpisodesDao = database.episodesDao() + + @Provides + @Singleton + fun providePodcastFollowedEntryDao( + database: JetcasterDatabase + ): PodcastFollowedEntryDao = database.podcastFollowedEntryDao() + + @Provides + @Singleton + fun provideTransactionRunner( + database: JetcasterDatabase + ): TransactionRunner = database.transactionRunnerDao() + + @Provides + @Singleton + fun provideSyndFeedInput() = SyndFeedInput() + + @Provides + @Dispatcher(JetcasterDispatchers.IO) + @Singleton + fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Dispatcher(JetcasterDispatchers.Main) + @Singleton + fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + + @Provides + @Singleton + fun provideEpisodeStore( + episodeDao: EpisodesDao + ): EpisodeStore = LocalEpisodeStore(episodeDao) + + @Provides + @Singleton + fun providePodcastStore( + podcastDao: PodcastsDao, + podcastFollowedEntryDao: PodcastFollowedEntryDao, + transactionRunner: TransactionRunner, + ): PodcastStore = LocalPodcastStore( + podcastDao = podcastDao, + podcastFollowedEntryDao = podcastFollowedEntryDao, + transactionRunner = transactionRunner + ) + + @Provides + @Singleton + fun provideCategoryStore( + categoriesDao: CategoriesDao, + podcastCategoryEntryDao: PodcastCategoryEntryDao, + podcastDao: PodcastsDao, + episodeDao: EpisodesDao, + ): CategoryStore = LocalCategoryStore( + episodesDao = episodeDao, + podcastsDao = podcastDao, + categoriesDao = categoriesDao, + categoryEntryDao = podcastCategoryEntryDao, + ) +} diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt similarity index 79% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt index 853dab9d82..216cce6b9d 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/Feeds.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/Feeds.kt @@ -14,19 +14,24 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.network /** * A hand selected list of feeds URLs used for the purposes of displaying real information * in this sample app. */ +private const val NowInAndroid = "https://feeds.libsyn.com/244409/rss" +private const val AndroidDevelopersBackstage = + "https://feeds.feedburner.com/blogspot/AndroidDevelopersBackstage" + val SampleFeeds = listOf( - "https://www.omnycontent.com/d/playlist/aaea4e69-af51-495e-afc9-a9760146922b/dc5b55ca-5f00-4063-b47f-ab870163d2b7/ca63aa52-ef7b-43ee-8ba5-ab8701645231/podcast.rss", + NowInAndroid, + AndroidDevelopersBackstage, + "https://www.omnycontent.com/d/playlist/aaea4e69-af51-495e-afc9-a9760146922b/" + + "dc5b55ca-5f00-4063-b47f-ab870163d2b7/ca63aa52-ef7b-43ee-8ba5-ab8701645231/podcast.rss", "https://audioboom.com/channels/2399216.rss", - "http://nowinandroid.googledevelopers.libsynpro.com/rss", "https://fragmentedpodcast.com/feed/", "https://feeds.megaphone.fm/replyall", - "http://feeds.feedburner.com/blogspot/AndroidDevelopersBackstage", "https://feeds.thisamericanlife.org/talpodcast", "https://feeds.npr.org/510289/podcast.xml", "https://feeds.99percentinvisible.org/99percentinvisible", diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt similarity index 92% rename from Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt rename to Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt index a47c27e56a..e572f23abb 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/data/OkHttpExtensions.kt +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/OkHttpExtensions.kt @@ -14,19 +14,21 @@ * limitations under the License. */ -package com.example.jetcaster.data +package com.example.jetcaster.core.data.network +import java.io.IOException +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback import okhttp3.Response import okhttp3.internal.closeQuietly -import java.io.IOException -import kotlin.coroutines.resumeWithException /** * Suspending wrapper around an OkHttp [Call], using [Call.enqueue]. */ +@OptIn(ExperimentalCoroutinesApi::class) suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> enqueue( object : Callback { diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.kt new file mode 100644 index 0000000000..60af89df98 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/network/PodcastFetcher.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.jetcaster.core.data.network + +import coil.network.HttpException +import com.example.jetcaster.core.data.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.Podcast +import com.rometools.modules.itunes.EntryInformation +import com.rometools.modules.itunes.FeedInformation +import com.rometools.rome.feed.synd.SyndEntry +import com.rometools.rome.feed.synd.SyndFeed +import com.rometools.rome.io.SyndFeedInput +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * A class which fetches some selected podcast RSS feeds. + * + * @param okHttpClient [OkHttpClient] to use for network requests + * @param syndFeedInput [SyndFeedInput] to use for parsing RSS feeds. + * @param ioDispatcher [CoroutineDispatcher] to use for running fetch requests. + */ +class PodcastsFetcher @Inject constructor( + private val okHttpClient: OkHttpClient, + private val syndFeedInput: SyndFeedInput, + @Dispatcher(JetcasterDispatchers.IO) private val ioDispatcher: CoroutineDispatcher +) { + + /** + * It seems that most podcast hosts do not implement HTTP caching appropriately. + * Instead of fetching data on every app open, we instead allow the use of 'stale' + * network responses (up to 8 hours). + */ + private val cacheControl by lazy { + CacheControl.Builder().maxStale(8, TimeUnit.HOURS).build() + } + + /** + * Returns a [Flow] which fetches each podcast feed and emits it in turn. + * + * The feeds are fetched concurrently, meaning that the resulting emission order may not + * match the order of [feedUrls]. + */ + operator fun invoke(feedUrls: List<String>): Flow<PodcastRssResponse> { + // We use flatMapMerge here to achieve concurrent fetching/parsing of the feeds. + return feedUrls.asFlow() + .flatMapMerge { feedUrl -> + flow { + emit(fetchPodcast(feedUrl)) + }.catch { e -> + // If an exception was caught while fetching the podcast, wrap it in + // an Error instance. + emit(PodcastRssResponse.Error(e)) + } + } + } + + private suspend fun fetchPodcast(url: String): PodcastRssResponse { + return withContext(ioDispatcher) { + val request = Request.Builder() + .url(url) + .cacheControl(cacheControl) + .build() + + val response = okHttpClient.newCall(request).execute() + + // If the network request wasn't successful, throw an exception + if (!response.isSuccessful) throw HttpException(response) + + // Otherwise we can parse the response using a Rome SyndFeedInput, then map it + // to a Podcast instance. We run this on the IO dispatcher since the parser is reading + // from a stream. + response.body!!.use { body -> + syndFeedInput.build(body.charStream()).toPodcastResponse(url) + } + } + } +} + +sealed class PodcastRssResponse { + data class Error( + val throwable: Throwable?, + ) : PodcastRssResponse() + + data class Success( + val podcast: Podcast, + val episodes: List<Episode>, + val categories: Set<Category> + ) : PodcastRssResponse() +} + +/** + * Map a Rome [SyndFeed] instance to our own [Podcast] data class. + */ +private fun SyndFeed.toPodcastResponse(feedUrl: String): PodcastRssResponse { + val podcastUri = uri ?: feedUrl + val episodes = entries.map { it.toEpisode(podcastUri) } + + val feedInfo = getModule(PodcastModuleDtd) as? FeedInformation + val podcast = Podcast( + uri = podcastUri, + title = title, + description = feedInfo?.summary ?: description, + author = author, + copyright = copyright, + imageUrl = feedInfo?.imageUri?.toString() + ) + + val categories = feedInfo?.categories + ?.map { Category(name = it.name) } + ?.toSet() ?: emptySet() + + return PodcastRssResponse.Success(podcast, episodes, categories) +} + +/** + * Map a Rome [SyndEntry] instance to our own [Episode] data class. + */ +private fun SyndEntry.toEpisode(podcastUri: String): Episode { + val entryInformation = getModule(PodcastModuleDtd) as? EntryInformation + return Episode( + uri = uri, + podcastUri = podcastUri, + title = title, + author = author, + summary = entryInformation?.summary ?: description?.value, + subtitle = entryInformation?.subtitle, + published = Instant.ofEpochMilli(publishedDate.time).atOffset(ZoneOffset.UTC), + duration = entryInformation?.duration?.milliseconds?.let { Duration.ofMillis(it) } + ) +} + +/** + * Most feeds use the following DTD to include extra information related to + * their podcast. Info such as images, summaries, duration, categories is sometimes only available + * via this attributes in this DTD. + */ +private const val PodcastModuleDtd = "http://www.itunes.com/dtds/podcast-1.0.dtd" diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt new file mode 100644 index 0000000000..0c29188054 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/CategoryStore.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.dao.CategoriesDao +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.dao.PodcastCategoryEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.PodcastCategoryEntry +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import kotlinx.coroutines.flow.Flow +interface CategoryStore { + /** + * Returns a flow containing a list of categories which is sorted by the number + * of podcasts in each category. + */ + fun categoriesSortedByPodcastCount( + limit: Int = Integer.MAX_VALUE + ): Flow<List<Category>> + + /** + * Returns a flow containing a list of podcasts in the category with the given [categoryId], + * sorted by the their last episode date. + */ + fun podcastsInCategorySortedByPodcastCount( + categoryId: Long, + limit: Int = Int.MAX_VALUE + ): Flow<List<PodcastWithExtraInfo>> + + /** + * Returns a flow containing a list of episodes from podcasts in the category with the + * given [categoryId], sorted by the their last episode date. + */ + fun episodesFromPodcastsInCategory( + categoryId: Long, + limit: Int = Integer.MAX_VALUE + ): Flow<List<EpisodeToPodcast>> + + /** + * Adds the category to the database if it doesn't already exist. + * + * @return the id of the newly inserted/existing category + */ + suspend fun addCategory(category: Category): Long + + suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) + + /** + * @return gets the category with [name], if it exists, otherwise, null + */ + fun getCategory(name: String): Flow<Category?> +} + +/** + * A data repository for [Category] instances. + */ +class LocalCategoryStore constructor( + private val categoriesDao: CategoriesDao, + private val categoryEntryDao: PodcastCategoryEntryDao, + private val episodesDao: EpisodesDao, + private val podcastsDao: PodcastsDao +) : CategoryStore { + /** + * Returns a flow containing a list of categories which is sorted by the number + * of podcasts in each category. + */ + override fun categoriesSortedByPodcastCount(limit: Int): Flow<List<Category>> { + return categoriesDao.categoriesSortedByPodcastCount(limit) + } + + /** + * Returns a flow containing a list of podcasts in the category with the given [categoryId], + * sorted by the their last episode date. + */ + override fun podcastsInCategorySortedByPodcastCount( + categoryId: Long, + limit: Int + ): Flow<List<PodcastWithExtraInfo>> { + return podcastsDao.podcastsInCategorySortedByLastEpisode(categoryId, limit) + } + + /** + * Returns a flow containing a list of episodes from podcasts in the category with the + * given [categoryId], sorted by the their last episode date. + */ + override fun episodesFromPodcastsInCategory( + categoryId: Long, + limit: Int + ): Flow<List<EpisodeToPodcast>> { + return episodesDao.episodesFromPodcastsInCategory(categoryId, limit) + } + + /** + * Adds the category to the database if it doesn't already exist. + * + * @return the id of the newly inserted/existing category + */ + override suspend fun addCategory(category: Category): Long { + return when (val local = categoriesDao.getCategoryWithName(category.name)) { + null -> categoriesDao.insert(category) + else -> local.id + } + } + + override suspend fun addPodcastToCategory(podcastUri: String, categoryId: Long) { + categoryEntryDao.insert( + PodcastCategoryEntry(podcastUri = podcastUri, categoryId = categoryId) + ) + } + + override fun getCategory(name: String): Flow<Category?> = + categoriesDao.observeCategory(name) +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt new file mode 100644 index 0000000000..26af92e97c --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.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.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.dao.EpisodesDao +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import kotlinx.coroutines.flow.Flow + +interface EpisodeStore { + /** + * Returns a flow containing the episode given [episodeUri]. + */ + fun episodeWithUri(episodeUri: String): Flow<Episode> + + /** + * Returns a flow containing the episode and corresponding podcast given an [episodeUri]. + */ + fun episodeAndPodcastWithUri(episodeUri: String): Flow<EpisodeToPodcast> + + /** + * Returns a flow containing the list of episodes associated with the podcast with the + * given [podcastUri]. + */ + fun episodesInPodcast( + podcastUri: String, + limit: Int = Integer.MAX_VALUE + ): Flow<List<EpisodeToPodcast>> + + /** + * Returns a list of episodes for the given podcast URIs ordering by most recently published + * to least recently published. + */ + fun episodesInPodcasts( + podcastUris: List<String>, + limit: Int = Integer.MAX_VALUE + ): Flow<List<EpisodeToPodcast>> + + /** + * Add a new [Episode] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + suspend fun addEpisodes(episodes: Collection<Episode>) + + suspend fun isEmpty(): Boolean +} + +/** + * A data repository for [Episode] instances. + */ +class LocalEpisodeStore( + private val episodesDao: EpisodesDao +) : EpisodeStore { + /** + * Returns a flow containing the episode given [episodeUri]. + */ + override fun episodeWithUri(episodeUri: String): Flow<Episode> { + return episodesDao.episode(episodeUri) + } + + override fun episodeAndPodcastWithUri(episodeUri: String): Flow<EpisodeToPodcast> = + episodesDao.episodeAndPodcast(episodeUri) + + /** + * Returns a flow containing the list of episodes associated with the podcast with the + * given [podcastUri]. + */ + override fun episodesInPodcast( + podcastUri: String, + limit: Int + ): Flow<List<EpisodeToPodcast>> { + return episodesDao.episodesForPodcastUri(podcastUri, limit) + } + /** + * Returns a list of episodes for the given podcast URIs ordering by most recently published + * to least recently published. + */ + override fun episodesInPodcasts( + podcastUris: List<String>, + limit: Int + ): Flow<List<EpisodeToPodcast>> = + episodesDao.episodesForPodcasts(podcastUris, limit) + + /** + * Add a new [Episode] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + override suspend fun addEpisodes(episodes: Collection<Episode>) = + episodesDao.insertAll(episodes) + + override suspend fun isEmpty(): Boolean = episodesDao.count() == 0 +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt new file mode 100644 index 0000000000..ee809c9e30 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastStore.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.database.dao.PodcastFollowedEntryDao +import com.example.jetcaster.core.data.database.dao.PodcastsDao +import com.example.jetcaster.core.data.database.dao.TransactionRunner +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastFollowedEntry +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import kotlinx.coroutines.flow.Flow + +interface PodcastStore { + /** + * Return a flow containing the [Podcast] with the given [uri]. + */ + fun podcastWithUri(uri: String): Flow<Podcast> + + /** + * Return a flow containing the [PodcastWithExtraInfo] with the given [podcastUri]. + */ + fun podcastWithExtraInfo(podcastUri: String): Flow<PodcastWithExtraInfo> + + /** + * Returns a flow containing the entire collection of podcasts, sorted by the last episode + * publish date for each podcast. + */ + fun podcastsSortedByLastEpisode( + limit: Int = Int.MAX_VALUE + ): Flow<List<PodcastWithExtraInfo>> + + /** + * Returns a flow containing a list of all followed podcasts, sorted by the their last + * episode date. + */ + fun followedPodcastsSortedByLastEpisode( + limit: Int = Int.MAX_VALUE + ): Flow<List<PodcastWithExtraInfo>> + + /** + * Returns a flow containing a list of podcasts such that its name partially matches + * with the specified keyword + */ + fun searchPodcastByTitle( + keyword: String, + limit: Int = Int.MAX_VALUE + ): Flow<List<PodcastWithExtraInfo>> + + /** + * Return a flow containing a list of podcast such that it belongs to the any of categories + * specified with categories parameter and its name partially matches with the specified + * keyword. + */ + fun searchPodcastByTitleAndCategories( + keyword: String, + categories: List<Category>, + limit: Int = Int.MAX_VALUE + ): Flow<List<PodcastWithExtraInfo>> + + suspend fun togglePodcastFollowed(podcastUri: String) + + suspend fun followPodcast(podcastUri: String) + + suspend fun unfollowPodcast(podcastUri: String) + + /** + * Add a new [Podcast] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + suspend fun addPodcast(podcast: Podcast) + + suspend fun isEmpty(): Boolean +} + +/** + * A data repository for [Podcast] instances. + */ +class LocalPodcastStore constructor( + private val podcastDao: PodcastsDao, + private val podcastFollowedEntryDao: PodcastFollowedEntryDao, + private val transactionRunner: TransactionRunner +) : PodcastStore { + /** + * Return a flow containing the [Podcast] with the given [uri]. + */ + override fun podcastWithUri(uri: String): Flow<Podcast> { + return podcastDao.podcastWithUri(uri) + } + + /** + * Return a flow containing the [PodcastWithExtraInfo] with the given [podcastUri]. + */ + override fun podcastWithExtraInfo(podcastUri: String): Flow<PodcastWithExtraInfo> = + podcastDao.podcastWithExtraInfo(podcastUri) + + /** + * Returns a flow containing the entire collection of podcasts, sorted by the last episode + * publish date for each podcast. + */ + override fun podcastsSortedByLastEpisode( + limit: Int + ): Flow<List<PodcastWithExtraInfo>> { + return podcastDao.podcastsSortedByLastEpisode(limit) + } + + /** + * Returns a flow containing a list of all followed podcasts, sorted by the their last + * episode date. + */ + override fun followedPodcastsSortedByLastEpisode( + limit: Int + ): Flow<List<PodcastWithExtraInfo>> { + return podcastDao.followedPodcastsSortedByLastEpisode(limit) + } + + override fun searchPodcastByTitle( + keyword: String, + limit: Int + ): Flow<List<PodcastWithExtraInfo>> { + return podcastDao.searchPodcastByTitle(keyword, limit) + } + + override fun searchPodcastByTitleAndCategories( + keyword: String, + categories: List<Category>, + limit: Int + ): Flow<List<PodcastWithExtraInfo>> { + val categoryIdList = categories.map { it.id } + return podcastDao.searchPodcastByTitleAndCategory(keyword, categoryIdList, limit) + } + + override suspend fun followPodcast(podcastUri: String) { + podcastFollowedEntryDao.insert(PodcastFollowedEntry(podcastUri = podcastUri)) + } + + override suspend fun togglePodcastFollowed(podcastUri: String) = transactionRunner { + if (podcastFollowedEntryDao.isPodcastFollowed(podcastUri)) { + unfollowPodcast(podcastUri) + } else { + followPodcast(podcastUri) + } + } + + override suspend fun unfollowPodcast(podcastUri: String) { + podcastFollowedEntryDao.deleteWithPodcastUri(podcastUri) + } + + /** + * Add a new [Podcast] to this store. + * + * This automatically switches to the main thread to maintain thread consistency. + */ + override suspend fun addPodcast(podcast: Podcast) { + podcastDao.insert(podcast) + } + + override suspend fun isEmpty(): Boolean = podcastDao.count() == 0 +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt new file mode 100644 index 0000000000..a102caf961 --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/data/repository/PodcastsRepository.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.data.repository + +import com.example.jetcaster.core.data.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.data.database.dao.TransactionRunner +import com.example.jetcaster.core.data.network.PodcastRssResponse +import com.example.jetcaster.core.data.network.PodcastsFetcher +import com.example.jetcaster.core.data.network.SampleFeeds +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Data repository for Podcasts. + */ +class PodcastsRepository @Inject constructor( + private val podcastsFetcher: PodcastsFetcher, + private val podcastStore: PodcastStore, + private val episodeStore: EpisodeStore, + private val categoryStore: CategoryStore, + private val transactionRunner: TransactionRunner, + @Dispatcher(JetcasterDispatchers.Main) mainDispatcher: CoroutineDispatcher +) { + private var refreshingJob: Job? = null + + private val scope = CoroutineScope(mainDispatcher) + + suspend fun updatePodcasts(force: Boolean) { + if (refreshingJob?.isActive == true) { + refreshingJob?.join() + } else if (force || podcastStore.isEmpty()) { + val job = scope.launch { + // Now fetch the podcasts, and add each to each store + podcastsFetcher(SampleFeeds) + .filter { it is PodcastRssResponse.Success } + .map { it as PodcastRssResponse.Success } + .collect { (podcast, episodes, categories) -> + transactionRunner { + podcastStore.addPodcast(podcast) + episodeStore.addEpisodes(episodes) + + categories.forEach { category -> + // First insert the category + val categoryId = categoryStore.addCategory(category) + // Now we can add the podcast to the category + categoryStore.addPodcastToCategory( + podcastUri = podcast.uri, + categoryId = categoryId + ) + } + } + } + } + refreshingJob = job + // We need to wait here for the job to finish, otherwise the coroutine completes ~immediatelly + job.join() + } + } +} diff --git a/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/util/Flows.kt b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/util/Flows.kt new file mode 100644 index 0000000000..a9940f315d --- /dev/null +++ b/Jetcaster/core/data/src/main/java/com/example/jetcaster/core/util/Flows.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.util + +import kotlinx.coroutines.flow.Flow +/** + * Combines 3 flows into a single flow by combining their latest values using the provided transform function. + * + * @param flow The first flow. + * @param flow2 The second flow. + * @param flow3 The third flow. + * @param transform The transform function to combine the latest values of the three flows. + * @return A flow that emits the results of the transform function applied to the latest values of the three flows. + */ +fun <T1, T2, T3, T4, T5, R> combine( + flow: Flow<T1>, + flow2: Flow<T2>, + flow3: Flow<T3>, + flow4: Flow<T4>, + flow5: Flow<T5>, + transform: suspend (T1, T2, T3, T4, T5) -> R +): Flow<R> = + kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + ) + } +fun <T1, T2, R> combine( + flow: Flow<T1>, + flow2: Flow<T2>, + + transform: suspend (T1, T2) -> R +): Flow<R> = + kotlinx.coroutines.flow.combine(flow, flow2) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + ) + } + +/** + * Combines six flows into a single flow by combining their latest values using the provided transform function. + * + * @param flow The first flow. + * @param flow2 The second flow. + * @param flow3 The third flow. + * @param flow4 The fourth flow. + * @param flow5 The fifth flow. + * @param flow6 The sixth flow. + * @param transform The transform function to combine the latest values of the six flows. + * @return A flow that emits the results of the transform function applied to the latest values of the six flows. + */ +fun <T1, T2, T3, T4, T5, T6, R> combine( + flow: Flow<T1>, + flow2: Flow<T2>, + flow3: Flow<T3>, + flow4: Flow<T4>, + flow5: Flow<T5>, + flow6: Flow<T6>, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R +): Flow<R> = + kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) + } + +/** + * Combines seven flows into a single flow by combining their latest values using the provided transform function. + * + * @param flow The first flow. + * @param flow2 The second flow. + * @param flow3 The third flow. + * @param flow4 The fourth flow. + * @param flow5 The fifth flow. + * @param flow6 The sixth flow. + * @param flow7 The seventh flow. + * @param transform The transform function to combine the latest values of the seven flows. + * @return A flow that emits the results of the transform function applied to the latest values of the seven flows. + */ +fun <T1, T2, T3, T4, T5, T6, T7, R> combine( + flow: Flow<T1>, + flow2: Flow<T2>, + flow3: Flow<T3>, + flow4: Flow<T4>, + flow5: Flow<T5>, + flow6: Flow<T6>, + flow7: Flow<T7>, + transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R +): Flow<R> = + kotlinx.coroutines.flow.combine( + flow, + flow2, + flow3, + flow4, + flow5, + flow6, + flow7 + ) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + ) + } diff --git a/Jetcaster/core/designsystem/.gitignore b/Jetcaster/core/designsystem/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/designsystem/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/designsystem/build.gradle.kts b/Jetcaster/core/designsystem/build.gradle.kts new file mode 100644 index 0000000000..3e4ba24cb3 --- /dev/null +++ b/Jetcaster/core/designsystem/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose) +} + +// TODO(chris): Set up convention plugin +android { + namespace = "com.example.jetcaster.core.designsystem" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + vectorDrawables.useSupportLibrary = true + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} +kotlin { + jvmToolchain(17) +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.text) + implementation(libs.coil.kt.compose) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) +} diff --git a/Jetcaster/core/designsystem/consumer-rules.pro b/Jetcaster/core/designsystem/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/designsystem/proguard-rules.pro b/Jetcaster/core/designsystem/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/designsystem/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/designsystem/src/main/AndroidManifest.xml b/Jetcaster/core/designsystem/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + +</manifest> diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/HtmlTextContainer.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/HtmlTextContainer.kt new file mode 100644 index 0000000000..e0e91040fe --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/HtmlTextContainer.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.component + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml + +/** + * A container for text that should be HTML formatted. This container will handle building the + * annotated string from [text], and enable text selection if [text] has any selectable element. + */ +@Composable +fun HtmlTextContainer( + text: String, + content: @Composable (AnnotatedString) -> Unit +) { + val annotatedString = remember(key1 = text) { + AnnotatedString.fromHtml(htmlString = text) + } + SelectionContainer { + content(annotatedString) + } +} diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt new file mode 100644 index 0000000000..4cb124dc65 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.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.designsystem.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.ContentScale +import coil.compose.AsyncImage + +@Composable +fun ImageBackgroundColorScrim( + url: String?, + color: Color, + modifier: Modifier = Modifier, +) { + ImageBackground( + url = url, + modifier = modifier, + overlay = { + drawRect(color) + } + ) +} + +@Composable +fun ImageBackgroundRadialGradientScrim( + url: String?, + colors: List<Color>, + modifier: Modifier = Modifier, +) { + ImageBackground( + url = url, + modifier = modifier, + overlay = { + val brush = Brush.radialGradient( + colors = colors, + center = Offset(0f, size.height), + radius = size.width * 1.5f + ) + drawRect(brush, blendMode = BlendMode.Multiply) + } + ) +} + +/** + * Displays an image scaled 150% overlaid by [overlay] + */ +@Composable +fun ImageBackground( + url: String?, + overlay: DrawScope.() -> Unit, + modifier: Modifier = Modifier, +) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .fillMaxWidth() + .drawWithCache { + onDrawWithContent { + drawContent() + overlay() + } + } + ) +} diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt new file mode 100644 index 0000000000..f7b8966196 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import com.example.jetcaster.core.designsystem.R + +@Composable +fun PodcastImage( + podcastImageUrl: String, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, + placeholderBrush: Brush = thumbnailPlaceholderDefaultBrush(), +) { + if (LocalInspectionMode.current) { + Box(modifier = modifier.background(MaterialTheme.colorScheme.primary)) + return + } + + var imagePainterState by remember { + mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty) + } + + val imageLoader = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(podcastImageUrl) + .crossfade(true) + .build(), + contentScale = contentScale, + onState = { state -> imagePainterState = state } + ) + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + when (imagePainterState) { + is AsyncImagePainter.State.Loading, + is AsyncImagePainter.State.Error -> { + Image( + painter = painterResource(id = R.drawable.img_empty), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + ) + } + else -> { + Box( + modifier = Modifier + .background(placeholderBrush) + .fillMaxSize() + + ) + } + } + + Image( + painter = imageLoader, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier, + ) + } +} diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/thumbnailPlaceholder.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/thumbnailPlaceholder.kt new file mode 100644 index 0000000000..865dac3130 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/component/thumbnailPlaceholder.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.component + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import com.example.jetcaster.designsystem.theme.surfaceVariantDark +import com.example.jetcaster.designsystem.theme.surfaceVariantLight + +@Composable +internal fun thumbnailPlaceholderDefaultBrush( + color: Color = thumbnailPlaceHolderDefaultColor() +): Brush { + return SolidColor(color) +} + +@Composable +private fun thumbnailPlaceHolderDefaultColor( + isInDarkMode: Boolean = isSystemInDarkTheme() +): Color { + return if (isInDarkMode) { + surfaceVariantDark + } else { + surfaceVariantLight + } +} diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt new file mode 100644 index 0000000000..51ab6000bc --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Color.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF885200) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFFFAC46) +val onPrimaryContainerLight = Color(0xFF482900) +val secondaryLight = Color(0xFF7A5817) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFFFD798) +val onSecondaryContainerLight = Color(0xFF5C3F00) +val tertiaryLight = Color(0xFF994700) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFFF801F) +val onTertiaryContainerLight = Color(0xFF2D1000) +val errorLight = Color(0xFFA4384A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFF87889) +val onErrorContainerLight = Color(0xFF32000A) +val backgroundLight = Color(0xFFFFF8F4) +val onBackgroundLight = Color(0xFF221A11) +val surfaceLight = Color(0xFFFFF8F4) +val onSurfaceLight = Color(0xFF221A11) +val surfaceVariantLight = Color(0xFFF7DEC8) +val onSurfaceVariantLight = Color(0xFF544434) +val outlineLight = Color(0xFF877461) +val outlineVariantLight = Color(0xFFDAC3AD) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF382F25) +val inverseOnSurfaceLight = Color(0xFFFFEEDF) +val inversePrimaryLight = Color(0xFFFFB868) +val surfaceDimLight = Color(0xFFE8D7C9) +val surfaceBrightLight = Color(0xFFFFF8F4) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFFFF1E6) +val surfaceContainerLight = Color(0xFFFCEBDC) +val surfaceContainerHighLight = Color(0xFFF6E5D7) +val surfaceContainerHighestLight = Color(0xFFF1E0D1) + +val primaryLightMediumContrast = Color(0xFF623A00) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFFA76600) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF5A3D00) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF936E2B) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF6F3100) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFFBC5800) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF7F1B30) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFC14E5F) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFFFF8F4) +val onBackgroundLightMediumContrast = Color(0xFF221A11) +val surfaceLightMediumContrast = Color(0xFFFFF8F4) +val onSurfaceLightMediumContrast = Color(0xFF221A11) +val surfaceVariantLightMediumContrast = Color(0xFFF7DEC8) +val onSurfaceVariantLightMediumContrast = Color(0xFF504030) +val outlineLightMediumContrast = Color(0xFF6E5C4A) +val outlineVariantLightMediumContrast = Color(0xFF8B7765) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF382F25) +val inverseOnSurfaceLightMediumContrast = Color(0xFFFFEEDF) +val inversePrimaryLightMediumContrast = Color(0xFFFFB868) +val surfaceDimLightMediumContrast = Color(0xFFE8D7C9) +val surfaceBrightLightMediumContrast = Color(0xFFFFF8F4) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFFFF1E6) +val surfaceContainerLightMediumContrast = Color(0xFFFCEBDC) +val surfaceContainerHighLightMediumContrast = Color(0xFFF6E5D7) +val surfaceContainerHighestLightMediumContrast = Color(0xFFF1E0D1) + +val primaryLightHighContrast = Color(0xFF351D00) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF623A00) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF301F00) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF5A3D00) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF3C1800) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF6F3100) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF4C0014) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF7F1B30) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFFFF8F4) +val onBackgroundLightHighContrast = Color(0xFF221A11) +val surfaceLightHighContrast = Color(0xFFFFF8F4) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFF7DEC8) +val onSurfaceVariantLightHighContrast = Color(0xFF2E2113) +val outlineLightHighContrast = Color(0xFF504030) +val outlineVariantLightHighContrast = Color(0xFF504030) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF382F25) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFFFFE8D4) +val surfaceDimLightHighContrast = Color(0xFFE8D7C9) +val surfaceBrightLightHighContrast = Color(0xFFFFF8F4) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFFFF1E6) +val surfaceContainerLightHighContrast = Color(0xFFFCEBDC) +val surfaceContainerHighLightHighContrast = Color(0xFFF6E5D7) +val surfaceContainerHighestLightHighContrast = Color(0xFFF1E0D1) + +val primaryDark = Color(0xFFFFCF9E) +val onPrimaryDark = Color(0xFF482900) +val primaryContainerDark = Color(0xFFF79900) +val onPrimaryContainerDark = Color(0xFF371E00) +val secondaryDark = Color(0xFFFFFEFF) +val onSecondaryDark = Color(0xFF422C00) +val secondaryContainerDark = Color(0xFFFBCC80) +val onSecondaryContainerDark = Color(0xFF553A00) +val tertiaryDark = Color(0xFFFFB68B) +val onTertiaryDark = Color(0xFF522300) +val tertiaryContainerDark = Color(0xFFE76E00) +val onTertiaryContainerDark = Color(0xFF000000) +val errorDark = Color(0xFFFFB2B9) +val onErrorDark = Color(0xFF65041F) +val errorContainerDark = Color(0xFFC14E5F) +val onErrorContainerDark = Color(0xFFFFFFFF) +val backgroundDark = Color(0xFF1A120A) +val onBackgroundDark = Color(0xFFF1E0D1) +val surfaceDark = Color(0xFF1A120A) +val onSurfaceDark = Color(0xFFF1E0D1) +val surfaceVariantDark = Color(0xFF544434) +val onSurfaceVariantDark = Color(0xFFDAC3AD) +val outlineDark = Color(0xFFA28D7A) +val outlineVariantDark = Color(0xFF544434) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFF1E0D1) +val inverseOnSurfaceDark = Color(0xFF382F25) +val inversePrimaryDark = Color(0xFF885200) +val surfaceDimDark = Color(0xFF1A120A) +val surfaceBrightDark = Color(0xFF42372D) +val surfaceContainerLowestDark = Color(0xFF140D06) +val surfaceContainerLowDark = Color(0xFF221A11) +val surfaceContainerDark = Color(0xFF271E15) +val surfaceContainerHighDark = Color(0xFF32281F) +val surfaceContainerHighestDark = Color(0xFF3D3329) + +val primaryDarkMediumContrast = Color(0xFFFFCF9E) +val onPrimaryDarkMediumContrast = Color(0xFF351D00) +val primaryContainerDarkMediumContrast = Color(0xFFF79900) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFFFFEFF) +val onSecondaryDarkMediumContrast = Color(0xFF422C00) +val secondaryContainerDarkMediumContrast = Color(0xFFFBCC80) +val onSecondaryContainerDarkMediumContrast = Color(0xFF2C1C00) +val tertiaryDarkMediumContrast = Color(0xFFFFBC95) +val onTertiaryDarkMediumContrast = Color(0xFF2A0E00) +val tertiaryContainerDarkMediumContrast = Color(0xFFE76E00) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFB8BE) +val onErrorDarkMediumContrast = Color(0xFF36000C) +val errorContainerDarkMediumContrast = Color(0xFFE5697A) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF1A120A) +val onBackgroundDarkMediumContrast = Color(0xFFF1E0D1) +val surfaceDarkMediumContrast = Color(0xFF1A120A) +val onSurfaceDarkMediumContrast = Color(0xFFFFFAF8) +val surfaceVariantDarkMediumContrast = Color(0xFF544434) +val onSurfaceVariantDarkMediumContrast = Color(0xFFDEC7B1) +val outlineDarkMediumContrast = Color(0xFFB59F8B) +val outlineVariantDarkMediumContrast = Color(0xFF93806D) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFF1E0D1) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF32281F) +val inversePrimaryDarkMediumContrast = Color(0xFF693E00) +val surfaceDimDarkMediumContrast = Color(0xFF1A120A) +val surfaceBrightDarkMediumContrast = Color(0xFF42372D) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF140D06) +val surfaceContainerLowDarkMediumContrast = Color(0xFF221A11) +val surfaceContainerDarkMediumContrast = Color(0xFF271E15) +val surfaceContainerHighDarkMediumContrast = Color(0xFF32281F) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3D3329) + +val primaryDarkHighContrast = Color(0xFFFFFAF8) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFFFFBE76) +val onPrimaryContainerDarkHighContrast = Color(0xFF000000) +val secondaryDarkHighContrast = Color(0xFFFFFEFF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFFBCC80) +val onSecondaryContainerDarkHighContrast = Color(0xFF000000) +val tertiaryDarkHighContrast = Color(0xFFFFFAF8) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFFFBC95) +val onTertiaryContainerDarkHighContrast = Color(0xFF000000) +val errorDarkHighContrast = Color(0xFFFFF9F9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFB8BE) +val onErrorContainerDarkHighContrast = Color(0xFF000000) +val backgroundDarkHighContrast = Color(0xFF1A120A) +val onBackgroundDarkHighContrast = Color(0xFFF1E0D1) +val surfaceDarkHighContrast = Color(0xFF1A120A) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF544434) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFAF8) +val outlineDarkHighContrast = Color(0xFFDEC7B1) +val outlineVariantDarkHighContrast = Color(0xFFDEC7B1) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFF1E0D1) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF3F2400) +val surfaceDimDarkHighContrast = Color(0xFF1A120A) +val surfaceBrightDarkHighContrast = Color(0xFF42372D) +val surfaceContainerLowestDarkHighContrast = Color(0xFF140D06) +val surfaceContainerLowDarkHighContrast = Color(0xFF221A11) +val surfaceContainerDarkHighContrast = Color(0xFF271E15) +val surfaceContainerHighDarkHighContrast = Color(0xFF32281F) +val surfaceContainerHighestDarkHighContrast = Color(0xFF3D3329) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt similarity index 90% rename from Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt rename to Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt index 5242395c1c..4340443cbf 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/theme/Keylines.kt +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Keylines.kt @@ -14,8 +14,8 @@ * limitations under the License. */ -package com.example.jetcaster.ui.theme +package com.example.jetcaster.designsystem.theme import androidx.compose.ui.unit.dp -val Keyline1 = 24.dp +val Keyline1 = 16.dp diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt new file mode 100644 index 0000000000..41bbfefbb6 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Shape.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val JetcasterShapes = Shapes( + small = RoundedCornerShape(percent = 50), + medium = RoundedCornerShape(size = 8.dp), + large = RoundedCornerShape(size = 16.dp) +) diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt new file mode 100644 index 0000000000..b9d6eb171e --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Type.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val JetcasterTypography = androidx.compose.material3.Typography( + displayLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 57.sp, + fontWeight = FontWeight.W400, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = Montserrat, + fontSize = 45.sp, + fontWeight = FontWeight.W400, + lineHeight = 52.sp + ), + displaySmall = TextStyle( + fontFamily = Montserrat, + fontSize = 36.sp, + fontWeight = FontWeight.W400, + lineHeight = 44.sp + ), + headlineLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 32.sp, + fontWeight = FontWeight.W500, + lineHeight = 40.sp + ), + headlineMedium = TextStyle( + fontFamily = Montserrat, + fontSize = 28.sp, + fontWeight = FontWeight.W500, + lineHeight = 36.sp + ), + headlineSmall = TextStyle( + fontFamily = Montserrat, + fontSize = 24.sp, + fontWeight = FontWeight.W500, + lineHeight = 32.sp + ), + titleLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 22.sp, + fontWeight = FontWeight.W400, + lineHeight = 28.sp + ), + titleMedium = TextStyle( + fontFamily = Montserrat, + fontSize = 16.sp, + fontWeight = FontWeight.W500, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = Montserrat, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = Montserrat, + fontSize = 12.sp, + fontWeight = FontWeight.W500, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = Montserrat, + fontSize = 11.sp, + fontWeight = FontWeight.W500, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + bodyLarge = TextStyle( + fontFamily = Montserrat, + fontSize = 16.sp, + fontWeight = FontWeight.W500, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = Montserrat, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = Montserrat, + fontSize = 12.sp, + fontWeight = FontWeight.W500, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), +) diff --git a/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt new file mode 100644 index 0000000000..bac67e41f7 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/java/com/example/jetcaster/designsystem/theme/Typography.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.designsystem.theme + +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import com.example.jetcaster.core.designsystem.R + +val Montserrat = FontFamily( + Font(R.font.montserrat_light, FontWeight.Light), + Font(R.font.montserrat_regular, FontWeight.Normal), + Font(R.font.montserrat_medium, FontWeight.Medium), + Font(R.font.montserrat_semibold, FontWeight.SemiBold) +) diff --git a/Jetcaster/core/designsystem/src/main/res/drawable/img_empty.xml b/Jetcaster/core/designsystem/src/main/res/drawable/img_empty.xml new file mode 100644 index 0000000000..46b27de1d1 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/res/drawable/img_empty.xml @@ -0,0 +1,61 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="180dp" + android:height="180dp" + android:viewportWidth="180" + android:viewportHeight="180"> + <path + android:pathData="M0,0h180v180h-180z"> + <aapt:attr name="android:fillColor"> + <gradient + android:startX="90" + android:startY="0" + android:endX="90" + android:endY="180" + android:type="linear"> + <item android:offset="0" android:color="@color/surface_bright"/> + <item android:offset="1" android:color="@color/background"/> + </gradient> + </aapt:attr> + </path> + <group> + <clip-path + android:pathData="M56.67,123.52l66.85,-0l0,-66.85l-66.85,-0z"/> + <path + android:pathData="M80.54,108.49C77.92,108.49 75.77,107.33 75.77,105.9V59.26C75.77,57.83 77.92,56.67 80.54,56.67C83.17,56.67 85.32,57.83 85.32,59.26V105.9C85.32,107.33 83.17,108.49 80.54,108.49Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M61.44,92.95C58.82,92.95 56.67,91.78 56.67,90.35V74.81C56.67,73.38 58.82,72.22 61.44,72.22C64.07,72.22 66.22,73.38 66.22,74.81V90.35C66.22,91.78 64.07,92.95 61.44,92.95Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M99.64,98.13C97.02,98.13 94.87,96.96 94.87,95.54V69.62C94.87,68.2 97.02,67.03 99.64,67.03C102.27,67.03 104.42,68.2 104.42,69.62V95.54C104.42,96.96 102.27,98.13 99.64,98.13Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M118.74,90.35C116.11,90.35 113.97,89.19 113.97,87.76V77.4C113.97,75.97 116.11,74.81 118.74,74.81C121.37,74.81 123.52,75.97 123.52,77.4V87.76C123.52,89.19 121.37,90.35 118.74,90.35Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M80.54,115.46C77.92,115.46 75.77,114.29 75.77,112.86V66.22C75.77,64.8 77.92,63.63 80.54,63.63C83.17,63.63 85.32,64.8 85.32,66.22V112.86C85.32,114.29 83.17,115.46 80.54,115.46Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M61.44,99.91C58.82,99.91 56.67,98.74 56.67,97.32V81.77C56.67,80.34 58.82,79.18 61.44,79.18C64.07,79.18 66.22,80.34 66.22,81.77V97.32C66.22,98.74 64.07,99.91 61.44,99.91Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M99.64,105.09C97.02,105.09 94.87,103.92 94.87,102.5V76.59C94.87,75.16 97.02,74 99.64,74C102.27,74 104.42,75.16 104.42,76.59V102.5C104.42,103.92 102.27,105.09 99.64,105.09Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M118.74,97.32C116.11,97.32 113.97,96.15 113.97,94.73V84.36C113.97,82.93 116.11,81.77 118.74,81.77C121.37,81.77 123.52,82.93 123.52,84.36V94.73C123.52,96.15 121.37,97.32 118.74,97.32Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M80.54,123.52C77.91,123.52 75.76,122.48 75.76,121.21V79.55C75.76,78.28 77.91,77.24 80.54,77.24C83.16,77.24 85.31,78.28 85.31,79.55V121.21C85.31,122.48 83.16,123.52 80.54,123.52Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M61.44,109.64C58.81,109.64 56.67,108.59 56.67,107.32V93.44C56.67,92.16 58.81,91.12 61.44,91.12C64.07,91.12 66.21,92.16 66.21,93.44V107.32C66.21,108.59 64.07,109.64 61.44,109.64Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M99.63,114.26C97.01,114.26 94.86,113.22 94.86,111.95V88.81C94.86,87.54 97.01,86.49 99.63,86.49C102.26,86.49 104.41,87.54 104.41,88.81V111.95C104.41,113.22 102.26,114.26 99.63,114.26Z" + android:fillColor="#1A120A"/> + <path + android:pathData="M118.73,107.32C116.11,107.32 113.96,106.28 113.96,105.01V95.75C113.96,94.48 116.11,93.44 118.73,93.44C121.36,93.44 123.5,94.48 123.5,95.75V105.01C123.5,106.28 121.36,107.32 118.73,107.32Z" + android:fillColor="#1A120A"/> + </group> +</vector> diff --git a/Jetcaster/app/src/main/res/font/montserrat_light.ttf b/Jetcaster/core/designsystem/src/main/res/font/montserrat_light.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_light.ttf rename to Jetcaster/core/designsystem/src/main/res/font/montserrat_light.ttf diff --git a/Jetcaster/app/src/main/res/font/montserrat_medium.ttf b/Jetcaster/core/designsystem/src/main/res/font/montserrat_medium.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_medium.ttf rename to Jetcaster/core/designsystem/src/main/res/font/montserrat_medium.ttf diff --git a/Jetcaster/app/src/main/res/font/montserrat_regular.ttf b/Jetcaster/core/designsystem/src/main/res/font/montserrat_regular.ttf similarity index 100% rename from Jetcaster/app/src/main/res/font/montserrat_regular.ttf rename to Jetcaster/core/designsystem/src/main/res/font/montserrat_regular.ttf diff --git a/JetNews/app/src/main/res/font/montserrat_semibold.ttf b/Jetcaster/core/designsystem/src/main/res/font/montserrat_semibold.ttf similarity index 100% rename from JetNews/app/src/main/res/font/montserrat_semibold.ttf rename to Jetcaster/core/designsystem/src/main/res/font/montserrat_semibold.ttf diff --git a/Jetcaster/core/designsystem/src/main/res/values-night/colors.xml b/Jetcaster/core/designsystem/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..148f321a7a --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="background">#FF1A120A</color> + <color name="surface_bright">#FF42372D</color> +</resources> diff --git a/Jetcaster/core/designsystem/src/main/res/values/colors.xml b/Jetcaster/core/designsystem/src/main/res/values/colors.xml new file mode 100644 index 0000000000..10f401c721 --- /dev/null +++ b/Jetcaster/core/designsystem/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="background">#FFFFF8F4</color> + <color name="surface_bright">#FFFFF8F4</color> +</resources> diff --git a/Jetcaster/core/domain-testing/.gitignore b/Jetcaster/core/domain-testing/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/domain-testing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/domain-testing/build.gradle.kts b/Jetcaster/core/domain-testing/build.gradle.kts new file mode 100644 index 0000000000..19d9964324 --- /dev/null +++ b/Jetcaster/core/domain-testing/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.example.jetcaster.core.domain.testing" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + kotlinOptions { + jvmTarget = "17" + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + implementation(projects.core.domain) + + coreLibraryDesugaring(libs.core.jdk.desugaring) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) +} diff --git a/Jetcaster/core/domain-testing/consumer-rules.pro b/Jetcaster/core/domain-testing/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/domain-testing/proguard-rules.pro b/Jetcaster/core/domain-testing/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/domain-testing/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/domain-testing/src/main/AndroidManifest.xml b/Jetcaster/core/domain-testing/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a5918e68ab --- /dev/null +++ b/Jetcaster/core/domain-testing/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + +</manifest> \ No newline at end of file diff --git a/Jetcaster/core/domain-testing/src/main/java/com/example/jetcaster/core/domain/testing/PreviewData.kt b/Jetcaster/core/domain-testing/src/main/java/com/example/jetcaster/core/domain/testing/PreviewData.kt new file mode 100644 index 0000000000..de1dfde9ab --- /dev/null +++ b/Jetcaster/core/domain-testing/src/main/java/com/example/jetcaster/core/domain/testing/PreviewData.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain.testing + +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.model.PodcastToEpisodeInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import java.time.OffsetDateTime +import java.time.ZoneOffset + +val PreviewCategories = listOf( + CategoryInfo(id = 1, name = "Crime"), + CategoryInfo(id = 2, name = "News"), + CategoryInfo(id = 3, name = "Comedy") +) + +val PreviewPodcasts = listOf( + PodcastInfo( + uri = "fakeUri://podcast/1", + title = "Android Developers Backstage", + author = "Android Developers", + isSubscribed = true, + lastEpisodeDate = OffsetDateTime.now() + ), + PodcastInfo( + uri = "fakeUri://podcast/2", + title = "Google Developers podcast", + author = "Google Developers", + lastEpisodeDate = OffsetDateTime.now() + ) +) + +val PreviewEpisodes = listOf( + EpisodeInfo( + uri = "fakeUri://episode/1", + title = "Episode 140: Lorem ipsum dolor", + summary = "In this episode, Romain, Chet and Tor talked with Mady Melor and Artur " + + "Tsurkan from the System UI team about... Bubbles!", + published = OffsetDateTime.of( + 2020, 6, 2, 9, + 27, 0, 0, ZoneOffset.of("-0800") + ) + ) +) + +val PreviewPlayerEpisodes = listOf( + PlayerEpisode( + PreviewPodcasts[0], + PreviewEpisodes[0] + ) +) + +val PreviewPodcastEpisodes = listOf( + PodcastToEpisodeInfo( + podcast = PreviewPodcasts[0], + episode = PreviewEpisodes[0], + ) +) diff --git a/Jetcaster/core/domain/.gitignore b/Jetcaster/core/domain/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/core/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/core/domain/build.gradle.kts b/Jetcaster/core/domain/build.gradle.kts new file mode 100644 index 0000000000..bca3291357 --- /dev/null +++ b/Jetcaster/core/domain/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "com.example.jetcaster.core.domain" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + kotlinOptions { + jvmTarget = "17" + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + coreLibraryDesugaring(libs.core.jdk.desugaring) + implementation(projects.core.data) + implementation(projects.core.dataTesting) + + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/Jetcaster/core/domain/consumer-rules.pro b/Jetcaster/core/domain/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Jetcaster/core/domain/proguard-rules.pro b/Jetcaster/core/domain/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/core/domain/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/core/domain/src/main/AndroidManifest.xml b/Jetcaster/core/domain/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8bdb7e14b3 --- /dev/null +++ b/Jetcaster/core/domain/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + +</manifest> diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/di/DomainDiModule.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/di/DomainDiModule.kt new file mode 100644 index 0000000000..13aa949a51 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/di/DomainDiModule.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.core.di + +import com.example.jetcaster.core.data.Dispatcher +import com.example.jetcaster.core.data.JetcasterDispatchers +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.MockEpisodePlayer +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher + +@Module +@InstallIn(SingletonComponent::class) +object DomainDiModule { + @Provides + @Singleton + fun provideEpisodePlayer( + @Dispatcher(JetcasterDispatchers.Main) mainDispatcher: CoroutineDispatcher + ): EpisodePlayer = MockEpisodePlayer(mainDispatcher) +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/FilterableCategoriesUseCase.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/FilterableCategoriesUseCase.kt new file mode 100644 index 0000000000..8b5f1a9b1c --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/FilterableCategoriesUseCase.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.asExternalModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Use case for categories that can be used to filter podcasts. + */ +class FilterableCategoriesUseCase @Inject constructor( + private val categoryStore: CategoryStore +) { + /** + * Created a [FilterableCategoriesModel] from the list of categories in [categoryStore]. + * @param selectedCategory the currently selected category. If null, the first category + * returned by the backing category list will be selected in the returned + * FilterableCategoriesModel + */ + operator fun invoke(selectedCategory: CategoryInfo?): Flow<FilterableCategoriesModel> = + categoryStore.categoriesSortedByPodcastCount() + .map { categories -> + FilterableCategoriesModel( + categories = categories.map { it.asExternalModel() }, + selectedCategory = selectedCategory + ?: categories.firstOrNull()?.asExternalModel() + ) + } +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCase.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCase.kt new file mode 100644 index 0000000000..7e72545254 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCase.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +/** + * A use case which returns all the latest episodes from all the podcasts the user follows. + */ +class GetLatestFollowedEpisodesUseCase @Inject constructor( + private val episodeStore: EpisodeStore, + private val podcastStore: PodcastStore, +) { + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(): Flow<List<EpisodeToPodcast>> = + podcastStore.followedPodcastsSortedByLastEpisode() + .flatMapLatest { followedPodcasts -> + episodeStore.episodesInPodcasts( + followedPodcasts.map { it.podcast.uri }, + followedPodcasts.size * 5 + ) + } +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCase.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCase.kt new file mode 100644 index 0000000000..97620a63c2 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCase.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.model.asPodcastToEpisodeInfo +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf + +/** + * A use case which returns top podcasts and matching episodes in a given [Category]. + */ +class PodcastCategoryFilterUseCase @Inject constructor( + private val categoryStore: CategoryStore +) { + operator fun invoke(category: CategoryInfo?): Flow<PodcastCategoryFilterResult> { + if (category == null) { + return flowOf(PodcastCategoryFilterResult()) + } + + val recentPodcastsFlow = categoryStore.podcastsInCategorySortedByPodcastCount( + category.id, + limit = 10 + ) + + val episodesFlow = categoryStore.episodesFromPodcastsInCategory( + category.id, + limit = 20 + ) + + // Combine our flows and collect them into the view state StateFlow + return combine(recentPodcastsFlow, episodesFlow) { topPodcasts, episodes -> + PodcastCategoryFilterResult( + topPodcasts = topPodcasts.map { it.asExternalModel() }, + episodes = episodes.map { it.asPodcastToEpisodeInfo() } + ) + } + } +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt new file mode 100644 index 0000000000..833ada892c --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/CategoryInfo.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import com.example.jetcaster.core.data.database.model.Category + +data class CategoryInfo( + val id: Long, + val name: String +) + +const val CategoryTechnology = "Technology" + +fun Category.asExternalModel() = + CategoryInfo( + id = id, + name = name + ) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt new file mode 100644 index 0000000000..8b8757bb8c --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/EpisodeInfo.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import com.example.jetcaster.core.data.database.model.Episode +import java.time.Duration +import java.time.OffsetDateTime + +/** + * External data layer representation of an episode. + */ +data class EpisodeInfo( + val uri: String = "", + val title: String = "", + val subTitle: String = "", + val summary: String = "", + val author: String = "", + val published: OffsetDateTime = OffsetDateTime.MIN, + val duration: Duration? = null, +) + +fun Episode.asExternalModel(): EpisodeInfo = + EpisodeInfo( + uri = uri, + title = title, + subTitle = subtitle ?: "", + summary = summary ?: "", + author = author ?: "", + published = published, + duration = duration, + ) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.kt new file mode 100644 index 0000000000..4cca646940 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/FilterableCategoriesModel.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.core.model + +/** + * Model holding a list of categories and a selected category in the collection + */ +data class FilterableCategoriesModel( + val categories: List<CategoryInfo> = emptyList(), + val selectedCategory: CategoryInfo? = null +) { + val isEmpty = categories.isEmpty() || selectedCategory == null +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/LibraryInfo.kt new file mode 100644 index 0000000000..5731b07f80 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/LibraryInfo.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.core.model + +data class LibraryInfo( + val episodes: List<PodcastToEpisodeInfo> = emptyList() +) : List<PodcastToEpisodeInfo> by episodes diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt new file mode 100644 index 0000000000..c0e6761ed0 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastCategoryFilterResult.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +/** + * A model holding top podcasts and matching episodes when filtering based on a category. + */ +data class PodcastCategoryFilterResult( + val topPodcasts: List<PodcastInfo> = emptyList(), + val episodes: List<PodcastToEpisodeInfo> = emptyList() +) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt new file mode 100644 index 0000000000..6f03fe56a7 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastInfo.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import java.time.OffsetDateTime + +/** + * External data layer representation of a podcast. + */ +data class PodcastInfo( + val uri: String = "", + val title: String = "", + val author: String = "", + val imageUrl: String = "", + val description: String = "", + val isSubscribed: Boolean? = null, + val lastEpisodeDate: OffsetDateTime? = null, +) + +fun Podcast.asExternalModel(): PodcastInfo = + PodcastInfo( + uri = this.uri, + title = this.title, + author = this.author ?: "", + imageUrl = this.imageUrl ?: "", + description = this.description ?: "", + ) + +fun PodcastWithExtraInfo.asExternalModel(): PodcastInfo = + this.podcast.asExternalModel().copy( + isSubscribed = isFollowed, + lastEpisodeDate = lastEpisodeDate, + ) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastToEpisodeInfo.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastToEpisodeInfo.kt new file mode 100644 index 0000000000..a7e458cad1 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/model/PodcastToEpisodeInfo.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.model + +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast + +data class PodcastToEpisodeInfo( + val episode: EpisodeInfo, + val podcast: PodcastInfo, +) + +fun EpisodeToPodcast.asPodcastToEpisodeInfo(): PodcastToEpisodeInfo = + PodcastToEpisodeInfo( + episode = episode.asExternalModel(), + podcast = podcast.asExternalModel(), + ) diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt new file mode 100644 index 0000000000..eb88fdef51 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.player + +import com.example.jetcaster.core.player.model.PlayerEpisode +import java.time.Duration +import kotlinx.coroutines.flow.StateFlow + +val DefaultPlaybackSpeed = Duration.ofSeconds(1) +data class EpisodePlayerState( + val currentEpisode: PlayerEpisode? = null, + val queue: List<PlayerEpisode> = emptyList(), + val playbackSpeed: Duration = DefaultPlaybackSpeed, + val isPlaying: Boolean = false, + val timeElapsed: Duration = Duration.ZERO, +) + +/** + * Interface definition for an episode player defining high-level functions such as queuing + * episodes, playing an episode, pausing, seeking, etc. + */ +interface EpisodePlayer { + + /** + * A StateFlow that emits the [EpisodePlayerState] as controls as invoked on this player. + */ + val playerState: StateFlow<EpisodePlayerState> + + /** + * Gets the current episode playing, or to be played, by this player. + */ + var currentEpisode: PlayerEpisode? + + /** + * The speed of which the player increments + */ + var playerSpeed: Duration + + fun addToQueue(episode: PlayerEpisode) + + /* + * Flushes the queue + */ + fun removeAllFromQueue() + + /** + * Plays the current episode + */ + fun play() + + /** + * Plays the specified episode + */ + fun play(playerEpisode: PlayerEpisode) + + /** + * Plays the specified list of episodes + */ + fun play(playerEpisodes: List<PlayerEpisode>) + + /** + * Pauses the currently played episode + */ + fun pause() + + /** + * Stops the currently played episode + */ + fun stop() + + /** + * Plays another episode in the queue (if available) + */ + fun next() + + /** + * Plays the previous episode in the queue (if available). Or if an episode is currently + * playing this will start the episode from the beginning + */ + fun previous() + + /** + * Advances a currently played episode by a given time interval specified in [duration]. + */ + fun advanceBy(duration: Duration) + + /** + * Rewinds a currently played episode by a given time interval specified in [duration]. + */ + fun rewindBy(duration: Duration) + + /** + * Signal that user started seeking. + */ + fun onSeekingStarted() + + /** + * Seeks to a given time interval specified in [duration]. + */ + fun onSeekingFinished(duration: Duration) + + /** + * Increases the speed of Player playback by a given time specified in [duration]. + */ + fun increaseSpeed(speed: Duration = Duration.ofMillis(500)) + + /** + * Decreases the speed of Player playback by a given time specified in [duration]. + */ + fun decreaseSpeed(speed: Duration = Duration.ofMillis(500)) +} diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt new file mode 100644 index 0000000000..f94a552b5b --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -0,0 +1,230 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.player + +import com.example.jetcaster.core.player.model.PlayerEpisode +import java.time.Duration +import kotlin.reflect.KProperty +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class MockEpisodePlayer( + private val mainDispatcher: CoroutineDispatcher +) : EpisodePlayer { + + private val _playerState = MutableStateFlow(EpisodePlayerState()) + private val _currentEpisode = MutableStateFlow<PlayerEpisode?>(null) + private val queue = MutableStateFlow<List<PlayerEpisode>>(emptyList()) + private val isPlaying = MutableStateFlow(false) + private val timeElapsed = MutableStateFlow(Duration.ZERO) + private val _playerSpeed = MutableStateFlow(DefaultPlaybackSpeed) + private val coroutineScope = CoroutineScope(mainDispatcher) + + private var timerJob: Job? = null + + init { + coroutineScope.launch { + // Combine streams here + combine( + _currentEpisode, + queue, + isPlaying, + timeElapsed, + _playerSpeed + ) { currentEpisode, queue, isPlaying, timeElapsed, playerSpeed -> + EpisodePlayerState( + currentEpisode = currentEpisode, + queue = queue, + isPlaying = isPlaying, + timeElapsed = timeElapsed, + playbackSpeed = playerSpeed + ) + }.catch { + // TODO handle error state + throw it + }.collect { + _playerState.value = it + } + } + } + + override var playerSpeed: Duration = _playerSpeed.value + + override val playerState: StateFlow<EpisodePlayerState> = _playerState.asStateFlow() + + override var currentEpisode: PlayerEpisode? by _currentEpisode + override fun addToQueue(episode: PlayerEpisode) { + queue.update { + it + episode + } + } + + override fun removeAllFromQueue() { + queue.value = emptyList() + } + + override fun play() { + // Do nothing if already playing + if (isPlaying.value) { + return + } + + val episode = _currentEpisode.value ?: return + + isPlaying.value = true + timerJob = coroutineScope.launch { + // Increment timer by a second + while (isActive && timeElapsed.value < episode.duration) { + delay(playerSpeed.toMillis()) + timeElapsed.update { it + playerSpeed } + } + + // Once done playing, see if + isPlaying.value = false + timeElapsed.value = Duration.ZERO + + if (hasNext()) { + next() + } + } + } + + override fun play(playerEpisode: PlayerEpisode) { + play(listOf(playerEpisode)) + } + + override fun play(playerEpisodes: List<PlayerEpisode>) { + if (isPlaying.value) { + pause() + } + + // Keep the currently playing episode in the queue + val playingEpisode = _currentEpisode.value + var previousList: List<PlayerEpisode> = emptyList() + queue.update { queue -> + playerEpisodes.map { episode -> + if (queue.contains(episode)) { + val mutableList = queue.toMutableList() + mutableList.remove(episode) + previousList = mutableList + } else { + previousList = queue + } + } + if (playingEpisode != null) { + playerEpisodes + listOf(playingEpisode) + previousList + } else { + playerEpisodes + previousList + } + } + + next() + } + + override fun pause() { + isPlaying.value = false + + timerJob?.cancel() + timerJob = null + } + + override fun stop() { + isPlaying.value = false + timeElapsed.value = Duration.ZERO + + timerJob?.cancel() + timerJob = null + } + + override fun advanceBy(duration: Duration) { + val currentEpisodeDuration = _currentEpisode.value?.duration ?: return + timeElapsed.update { + (it + duration).coerceAtMost(currentEpisodeDuration) + } + } + + override fun rewindBy(duration: Duration) { + timeElapsed.update { + (it - duration).coerceAtLeast(Duration.ZERO) + } + } + + override fun onSeekingStarted() { + // Need to pause the player so that it doesn't compete with timeline progression. + pause() + } + + override fun onSeekingFinished(duration: Duration) { + val currentEpisodeDuration = _currentEpisode.value?.duration ?: return + timeElapsed.update { duration.coerceIn(Duration.ZERO, currentEpisodeDuration) } + play() + } + + override fun increaseSpeed(speed: Duration) { + _playerSpeed.value += speed + } + + override fun decreaseSpeed(speed: Duration) { + _playerSpeed.value -= speed + } + + override fun next() { + val q = queue.value + if (q.isEmpty()) { + return + } + + timeElapsed.value = Duration.ZERO + val nextEpisode = q[0] + currentEpisode = nextEpisode + queue.value = q - nextEpisode + play() + } + + override fun previous() { + timeElapsed.value = Duration.ZERO + isPlaying.value = false + timerJob?.cancel() + timerJob = null + } + + private fun hasNext(): Boolean { + return queue.value.isNotEmpty() + } +} + +// Used to enable property delegation +private operator fun <T> MutableStateFlow<T>.setValue( + thisObj: Any?, + property: KProperty<*>, + value: T +) { + this.value = value +} + +private operator fun <T> MutableStateFlow<T>.getValue(thisObj: Any?, property: KProperty<*>): T = + this.value diff --git a/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/model/PlayerEpisode.kt b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/model/PlayerEpisode.kt new file mode 100644 index 0000000000..8ebd257a88 --- /dev/null +++ b/Jetcaster/core/domain/src/main/java/com/example/jetcaster/core/player/model/PlayerEpisode.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.player.model + +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import java.time.Duration +import java.time.OffsetDateTime + +/** + * Episode data with necessary information to be used within a player. + */ +data class PlayerEpisode( + val uri: String = "", + val title: String = "", + val subTitle: String = "", + val published: OffsetDateTime = OffsetDateTime.MIN, + val duration: Duration? = null, + val podcastName: String = "", + val author: String = "", + val summary: String = "", + val podcastImageUrl: String = "", +) { + constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo) : this( + title = episodeInfo.title, + subTitle = episodeInfo.subTitle, + published = episodeInfo.published, + duration = episodeInfo.duration, + podcastName = podcastInfo.title, + author = episodeInfo.author, + summary = episodeInfo.summary, + podcastImageUrl = podcastInfo.imageUrl, + uri = episodeInfo.uri + ) +} + +fun EpisodeToPodcast.toPlayerEpisode(): PlayerEpisode = + PlayerEpisode( + uri = episode.uri, + title = episode.title, + subTitle = episode.subtitle ?: "", + published = episode.published, + duration = episode.duration, + podcastName = podcast.title, + author = episode.author ?: podcast.author ?: "", + summary = episode.summary ?: "", + podcastImageUrl = podcast.imageUrl ?: "", + ) diff --git a/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/FilterableCategoriesUseCaseTest.kt b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/FilterableCategoriesUseCaseTest.kt new file mode 100644 index 0000000000..4431dc29f3 --- /dev/null +++ b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/FilterableCategoriesUseCaseTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.testing.repository.TestCategoryStore +import com.example.jetcaster.core.model.asExternalModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class FilterableCategoriesUseCaseTest { + + private val categoriesStore = TestCategoryStore() + private val testCategories = listOf( + Category(1, "News"), + Category(2, "Arts"), + Category(4, "Technology"), + Category(2, "TV & Film"), + ) + + val useCase = FilterableCategoriesUseCase( + categoryStore = categoriesStore + ) + + @Before + fun setUp() { + categoriesStore.setCategories(testCategories) + } + + @Test + fun whenNoSelectedCategory_onEmptySelectedCategoryInvoked() = runTest { + val filterableCategories = useCase(null).first() + assertEquals( + filterableCategories.categories[0], + filterableCategories.selectedCategory + ) + } + + @Test + fun whenSelectedCategory_correctFilterableCategoryIsSelected() = runTest { + val selectedCategory = testCategories[2] + val filterableCategories = useCase(selectedCategory.asExternalModel()).first() + assertEquals( + selectedCategory.asExternalModel(), + filterableCategories.selectedCategory + ) + } +} diff --git a/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCaseTest.kt b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCaseTest.kt new file mode 100644 index 0000000000..c2a3133ed1 --- /dev/null +++ b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/GetLatestFollowedEpisodesUseCaseTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain + +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.testing.repository.TestEpisodeStore +import com.example.jetcaster.core.data.testing.repository.TestPodcastStore +import java.time.OffsetDateTime +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class GetLatestFollowedEpisodesUseCaseTest { + + private val episodeStore = TestEpisodeStore() + private val podcastStore = TestPodcastStore() + + val useCase = GetLatestFollowedEpisodesUseCase( + episodeStore = episodeStore, + podcastStore = podcastStore + ) + + val testEpisodes = listOf( + Episode( + uri = "", + podcastUri = testPodcasts[0].podcast.uri, + title = "title1", + published = OffsetDateTime.MIN + ), + Episode( + uri = "", + podcastUri = testPodcasts[0].podcast.uri, + title = "title2", + published = OffsetDateTime.now() + ), + Episode( + uri = "", + podcastUri = testPodcasts[1].podcast.uri, + title = "title3", + published = OffsetDateTime.MAX + ) + ) + + @Test + fun whenNoFollowedPodcasts_emptyFlow() = runTest { + val result = useCase() + + episodeStore.addEpisodes(testEpisodes) + testPodcasts.forEach { + podcastStore.addPodcast(it.podcast) + } + + assertTrue(result.first().isEmpty()) + } + + @Test + fun whenFollowedPodcasts_nonEmptyFlow() = runTest { + val result = useCase() + + episodeStore.addEpisodes(testEpisodes) + testPodcasts.forEach { + podcastStore.addPodcast(it.podcast) + } + podcastStore.togglePodcastFollowed(testPodcasts[0].podcast.uri) + + assertTrue(result.first().isNotEmpty()) + } + + @Test + fun whenFollowedPodcasts_sortedByPublished() = runTest { + val result = useCase() + + episodeStore.addEpisodes(testEpisodes) + testPodcasts.forEach { + podcastStore.addPodcast(it.podcast) + } + podcastStore.togglePodcastFollowed(testPodcasts[0].podcast.uri) + + result.first().zipWithNext { + ep1, ep2 -> + ep1.episode.published > ep2.episode.published + }.all { it } + } +} diff --git a/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCaseTest.kt new file mode 100644 index 0000000000..7568d175b8 --- /dev/null +++ b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/PodcastCategoryFilterUseCaseTest.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.core.domain + +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.testing.repository.TestCategoryStore +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.model.asPodcastToEpisodeInfo +import java.time.OffsetDateTime +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PodcastCategoryFilterUseCaseTest { + + private val categoriesStore = TestCategoryStore() + private val testEpisodeToPodcast = listOf( + EpisodeToPodcast().apply { + episode = Episode( + "", + "", + "Episode 1", + published = OffsetDateTime.now() + ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 1" + ) + ) + }, + EpisodeToPodcast().apply { + episode = Episode( + "", + "", + "Episode 2", + published = OffsetDateTime.now() + ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 2" + ) + ) + }, + EpisodeToPodcast().apply { + episode = Episode( + "", + "", + "Episode 3", + published = OffsetDateTime.now() + ) + _podcasts = listOf( + Podcast( + uri = "", + title = "Podcast 3" + ) + ) + } + ) + private val testCategory = Category(1, "Technology") + + val useCase = PodcastCategoryFilterUseCase( + categoryStore = categoriesStore + ) + + @Test + fun whenCategoryNull_emptyFlow() = runTest { + val resultFlow = useCase(null) + + categoriesStore.setEpisodesFromPodcast(testCategory.id, testEpisodeToPodcast) + categoriesStore.setPodcastsInCategory(testCategory.id, testPodcasts) + + val result = resultFlow.first() + assertTrue(result.topPodcasts.isEmpty()) + assertTrue(result.episodes.isEmpty()) + } + + @Test + fun whenCategoryNotNull_validFlow() = runTest { + val resultFlow = useCase(testCategory.asExternalModel()) + + categoriesStore.setEpisodesFromPodcast(testCategory.id, testEpisodeToPodcast) + categoriesStore.setPodcastsInCategory(testCategory.id, testPodcasts) + + val result = resultFlow.first() + assertEquals( + testPodcasts.map { it.asExternalModel() }, + result.topPodcasts + ) + assertEquals( + testEpisodeToPodcast.map { it.asPodcastToEpisodeInfo() }, + result.episodes + ) + } + + @Test + fun whenCategoryInfoNotNull_verifyLimitFlow() = runTest { + val resultFlow = useCase(testCategory.asExternalModel()) + + categoriesStore.setEpisodesFromPodcast( + testCategory.id, + List(8) { testEpisodeToPodcast }.flatten() + ) + categoriesStore.setPodcastsInCategory( + testCategory.id, + List(4) { testPodcasts }.flatten() + ) + + val result = resultFlow.first() + assertEquals(20, result.episodes.size) + assertEquals(10, result.topPodcasts.size) + } +} + +val testPodcasts = listOf( + PodcastWithExtraInfo().apply { + podcast = Podcast(uri = "nia", title = "Now in Android") + }, + PodcastWithExtraInfo().apply { + podcast = Podcast(uri = "adb", title = "Android Developers Backstage") + }, + PodcastWithExtraInfo().apply { + podcast = Podcast(uri = "techcrunch", title = "Techcrunch") + }, +) diff --git a/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/player/MockEpisodePlayerTest.kt b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/player/MockEpisodePlayerTest.kt new file mode 100644 index 0000000000..96c91a66ce --- /dev/null +++ b/Jetcaster/core/domain/src/test/kotlin/com/example/jetcaster/core/domain/player/MockEpisodePlayerTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.core.domain.player + +import com.example.jetcaster.core.player.MockEpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import java.time.Duration +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MockEpisodePlayerTest { + + private val testDispatcher = StandardTestDispatcher() + private val mockEpisodePlayer = MockEpisodePlayer(testDispatcher) + private val testEpisodes = listOf( + PlayerEpisode( + uri = "uri1", + duration = Duration.ofSeconds(60) + ), + PlayerEpisode( + uri = "uri2", + duration = Duration.ofSeconds(60) + ), + PlayerEpisode( + uri = "uri3", + duration = Duration.ofSeconds(60) + ), + ) + + @Test + fun whenPlay_incrementsByPlaySpeed() = runTest(testDispatcher) { + val playSpeed = Duration.ofSeconds(2) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60) + ) + mockEpisodePlayer.currentEpisode = currEpisode + mockEpisodePlayer.playerSpeed = playSpeed + + mockEpisodePlayer.play() + advanceTimeBy(playSpeed.toMillis() + 300) + + assertEquals(playSpeed, mockEpisodePlayer.playerState.value.timeElapsed) + } + + @Test + fun whenPlayDone_playerAutoPlaysNextEpisode() = runTest(testDispatcher) { + val duration = Duration.ofSeconds(60) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = duration + ) + mockEpisodePlayer.currentEpisode = currEpisode + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(duration.toMillis() + 1) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenNext_queueIsNotEmpty_autoPlaysNextEpisode() = runTest(testDispatcher) { + val duration = Duration.ofSeconds(60) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = duration + ) + + mockEpisodePlayer.currentEpisode = currEpisode + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertTrue(mockEpisodePlayer.playerState.value.isPlaying) + } + @Test + fun whenPlayListOfEpisodes_playerAutoPlaysNextEpisode() = runTest(testDispatcher) { + val duration = Duration.ofSeconds(60) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = duration + ) + val firstEpisodeFromList = PlayerEpisode( + uri = "firstEpisodeFromList", + duration = duration + ) + val secondEpisodeFromList = PlayerEpisode( + uri = "secondEpisodeFromList", + duration = duration + ) + val episodeListToBeAddedToTheQueue: List<PlayerEpisode> = listOf( + firstEpisodeFromList, secondEpisodeFromList + ) + mockEpisodePlayer.currentEpisode = currEpisode + + mockEpisodePlayer.play(episodeListToBeAddedToTheQueue) + assertEquals(firstEpisodeFromList, mockEpisodePlayer.currentEpisode) + + advanceTimeBy(duration.toMillis() + 1) + assertEquals(secondEpisodeFromList, mockEpisodePlayer.currentEpisode) + + advanceTimeBy(duration.toMillis() + 1) + assertEquals(currEpisode, mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenNext_queueIsEmpty_doesNothing() { + val episode = testEpisodes[0] + mockEpisodePlayer.currentEpisode = episode + mockEpisodePlayer.play() + + mockEpisodePlayer.next() + + assertEquals(episode, mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenAddToQueue_queueIsNotEmpty() = runTest(testDispatcher) { + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + advanceUntilIdle() + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size, queue.size) + testEpisodes.forEachIndexed { index, playerEpisode -> + assertEquals(playerEpisode, queue[index]) + } + } + + @Test + fun whenNext_queueIsNotEmpty_removeFromQueue() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60) + ) + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(100) + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size - 1, queue.size) + } + + @Test + fun whenNext_queueIsNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60) + ) + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(100) + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size - 1, queue.size) + } + + @Test + fun whenPrevious_queueIsEmpty_resetSameEpisode() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = testEpisodes[0] + mockEpisodePlayer.play() + advanceTimeBy(1000L) + + mockEpisodePlayer.previous() + assertEquals(0, mockEpisodePlayer.playerState.value.timeElapsed.toMillis()) + assertEquals(testEpisodes[0], mockEpisodePlayer.currentEpisode) + } +} diff --git a/Jetcaster/debug.keystore b/Jetcaster/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Jetcaster/debug.keystore and /dev/null differ diff --git a/Jetcaster/debug_2.keystore b/Jetcaster/debug_2.keystore new file mode 100644 index 0000000000..b42c971788 Binary files /dev/null and b/Jetcaster/debug_2.keystore differ diff --git a/Jetcaster/docs/screenshots.png b/Jetcaster/docs/screenshots.png new file mode 100644 index 0000000000..83c48db44d Binary files /dev/null and b/Jetcaster/docs/screenshots.png differ diff --git a/Jetcaster/docs/tabletop.png b/Jetcaster/docs/tabletop.png new file mode 100644 index 0000000000..d3863658fc Binary files /dev/null and b/Jetcaster/docs/tabletop.png differ diff --git a/Jetcaster/glancewidget/.gitignore b/Jetcaster/glancewidget/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/Jetcaster/glancewidget/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Jetcaster/glancewidget/build.gradle.kts b/Jetcaster/glancewidget/build.gradle.kts new file mode 100644 index 0000000000..a29291be40 --- /dev/null +++ b/Jetcaster/glancewidget/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose) +} + +android { + namespace = "com.example.jetcaster.glancewidget" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + buildFeatures { + compose = true + buildConfig = true + } + kotlinOptions { + jvmTarget = "17" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.material3) + implementation(libs.androidx.glance) + + implementation(libs.coil.kt.compose) + + implementation(libs.androidx.core.ktx) + implementation(libs.android.material3) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(projects.core.designsystem) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/Jetcaster/glancewidget/proguard-rules.pro b/Jetcaster/glancewidget/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/Jetcaster/glancewidget/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/AndroidManifest.xml b/Jetcaster/glancewidget/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..993c1df215 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <application> + <receiver + android:name="com.example.jetcaster.glancewidget.JetcasterAppWidgetReceiver" + android:enabled="true" + android:label="@string/app_widget_description" + android:exported="false"> + <intent-filter> + <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> + </intent-filter> + + <meta-data + android:name="android.appwidget.provider" + android:resource="@xml/jetcaster_info" /> + </receiver> + </application> + +</manifest> \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/Colors.kt b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/Colors.kt new file mode 100644 index 0000000000..8aa3901790 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/Colors.kt @@ -0,0 +1,172 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.glancewidget + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import com.example.jetcaster.designsystem.theme.backgroundDark +import com.example.jetcaster.designsystem.theme.backgroundLight +import com.example.jetcaster.designsystem.theme.errorContainerDark +import com.example.jetcaster.designsystem.theme.errorContainerLight +import com.example.jetcaster.designsystem.theme.errorDark +import com.example.jetcaster.designsystem.theme.errorLight +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight +import com.example.jetcaster.designsystem.theme.inversePrimaryDark +import com.example.jetcaster.designsystem.theme.inversePrimaryLight +import com.example.jetcaster.designsystem.theme.inverseSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseSurfaceLight +import com.example.jetcaster.designsystem.theme.onBackgroundDark +import com.example.jetcaster.designsystem.theme.onBackgroundLight +import com.example.jetcaster.designsystem.theme.onErrorContainerDark +import com.example.jetcaster.designsystem.theme.onErrorContainerLight +import com.example.jetcaster.designsystem.theme.onErrorDark +import com.example.jetcaster.designsystem.theme.onErrorLight +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight +import com.example.jetcaster.designsystem.theme.onPrimaryDark +import com.example.jetcaster.designsystem.theme.onPrimaryLight +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight +import com.example.jetcaster.designsystem.theme.onSecondaryDark +import com.example.jetcaster.designsystem.theme.onSecondaryLight +import com.example.jetcaster.designsystem.theme.onSurfaceDark +import com.example.jetcaster.designsystem.theme.onSurfaceLight +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight +import com.example.jetcaster.designsystem.theme.onTertiaryDark +import com.example.jetcaster.designsystem.theme.onTertiaryLight +import com.example.jetcaster.designsystem.theme.outlineDark +import com.example.jetcaster.designsystem.theme.outlineLight +import com.example.jetcaster.designsystem.theme.outlineVariantDark +import com.example.jetcaster.designsystem.theme.outlineVariantLight +import com.example.jetcaster.designsystem.theme.primaryContainerDark +import com.example.jetcaster.designsystem.theme.primaryContainerLight +import com.example.jetcaster.designsystem.theme.primaryDark +import com.example.jetcaster.designsystem.theme.primaryLight +import com.example.jetcaster.designsystem.theme.scrimDark +import com.example.jetcaster.designsystem.theme.scrimLight +import com.example.jetcaster.designsystem.theme.secondaryContainerDark +import com.example.jetcaster.designsystem.theme.secondaryContainerLight +import com.example.jetcaster.designsystem.theme.secondaryDark +import com.example.jetcaster.designsystem.theme.secondaryLight +import com.example.jetcaster.designsystem.theme.surfaceBrightDark +import com.example.jetcaster.designsystem.theme.surfaceBrightLight +import com.example.jetcaster.designsystem.theme.surfaceContainerDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLight +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLight +import com.example.jetcaster.designsystem.theme.surfaceDark +import com.example.jetcaster.designsystem.theme.surfaceDimDark +import com.example.jetcaster.designsystem.theme.surfaceDimLight +import com.example.jetcaster.designsystem.theme.surfaceLight +import com.example.jetcaster.designsystem.theme.surfaceVariantDark +import com.example.jetcaster.designsystem.theme.surfaceVariantLight +import com.example.jetcaster.designsystem.theme.tertiaryContainerDark +import com.example.jetcaster.designsystem.theme.tertiaryContainerLight +import com.example.jetcaster.designsystem.theme.tertiaryDark +import com.example.jetcaster.designsystem.theme.tertiaryLight + +/** + * Todo, this is copied from the core module. Refactor colors out of that so we can reference them. + */ +private val lightJetcasterColors = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +/** + * Todo, this is copied from the core module. Refactor colors out of that so we can reference them. + */ +internal val DarkJetcasterColors = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) diff --git a/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidget.kt b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidget.kt new file mode 100644 index 0000000000..c932bbf1e2 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidget.kt @@ -0,0 +1,322 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.glancewidget + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.SquareIconButton +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import coil.ImageLoader +import coil.request.ErrorResult +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal val TAG = "JetcasterAppWidget" + +/** + * Implementation of App Widget functionality. + */ +class JetcasterAppWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget + get() = JetcasterAppWidget() +} + +data class JetcasterAppWidgetViewState( + val episodeTitle: String, + val podcastTitle: String, + val isPlaying: Boolean, + val albumArtUri: String, +) + +private object Sizes { + val short = 72.dp + val minWidth = 140.dp + val smallBucketCutoffWidth = 250.dp // anything from minWidth to this will have no title + + val normal = 80.dp + val medium = 56.dp + val condensed = 48.dp +} + +private enum class SizeBucket { Invalid, Narrow, Normal, NarrowShort, NormalShort } + +@Composable +private fun calculateSizeBucket(): SizeBucket { + val size: DpSize = LocalSize.current + val width = size.width + val height = size.height + + return when { + width < Sizes.minWidth -> SizeBucket.Invalid + width <= Sizes.smallBucketCutoffWidth -> + if (height >= Sizes.short) SizeBucket.Narrow else SizeBucket.NarrowShort + else -> + if (height >= Sizes.short) SizeBucket.Normal else SizeBucket.NormalShort + } +} + +class JetcasterAppWidget : GlanceAppWidget() { + override val sizeMode: SizeMode + get() = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + + val testState = JetcasterAppWidgetViewState( + episodeTitle = + "100 - Android 15 DP 1, Stable Studio Iguana, Cloud Photo Picker, and more!", + podcastTitle = "Now in Android", + isPlaying = false, + albumArtUri = "https://static.libsyn.com/p/assets/9/f/f/3/" + + "9ff3cb5dc6cfb3e2e5bbc093207a2619/NIA000_PodcastThumbnail.png" + ) + + provideContent { + val sizeBucket = calculateSizeBucket() + val playPauseIcon = if (testState.isPlaying) PlayPauseIcon.Pause else PlayPauseIcon.Play + val artUri = Uri.parse(testState.albumArtUri) + + GlanceTheme { + when (sizeBucket) { + SizeBucket.Invalid -> WidgetUiInvalidSize() + SizeBucket.Narrow -> Widget( + iconSize = Sizes.medium, + imageUri = artUri, + playPauseIcon = playPauseIcon + ) + + SizeBucket.Normal -> WidgetUiNormal( + iconSize = Sizes.normal, + title = testState.episodeTitle, + subtitle = testState.podcastTitle, + imageUri = artUri, + playPauseIcon = playPauseIcon + ) + + SizeBucket.NarrowShort -> Widget( + iconSize = Sizes.condensed, + imageUri = artUri, + playPauseIcon = playPauseIcon + ) + + SizeBucket.NormalShort -> WidgetUiNormal( + iconSize = Sizes.condensed, + title = testState.episodeTitle, + subtitle = testState.podcastTitle, + imageUri = artUri, + playPauseIcon = playPauseIcon + ) + } + } + } + } +} + +@Composable +private fun WidgetUiNormal( + title: String, + subtitle: String, + imageUri: Uri, + playPauseIcon: PlayPauseIcon, + iconSize: Dp, +) { + + Scaffold { + Row( + GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.Vertical.CenterVertically + ) { + AlbumArt(imageUri, GlanceModifier.size(iconSize)) + PodcastText(title, subtitle, modifier = GlanceModifier.padding(16.dp).defaultWeight()) + PlayPauseButton(GlanceModifier.size(iconSize), playPauseIcon, {}) + } + } +} + +@Composable +private fun Widget( + iconSize: Dp, + imageUri: Uri, + playPauseIcon: PlayPauseIcon, +) { + Scaffold(titleBar = {} /* title bar will be optional in scaffold in glance 1.1.0-beta3*/) { + Row( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.Vertical.CenterVertically + ) { + AlbumArt(imageUri, GlanceModifier.size(iconSize)) + Spacer(GlanceModifier.defaultWeight()) + PlayPauseButton(GlanceModifier.size(iconSize), playPauseIcon, {}) + } + } +} + +@Composable +private fun WidgetUiInvalidSize() { + Box(modifier = GlanceModifier.fillMaxSize().background(ColorProvider(Color.Magenta))) { + Text("invalid size") + } +} + +@Composable +private fun AlbumArt( + imageUri: Uri, + modifier: GlanceModifier = GlanceModifier +) { + WidgetAsyncImage(uri = imageUri, contentDescription = null, modifier = modifier) +} + +@Composable +fun PodcastText(title: String, subtitle: String, modifier: GlanceModifier = GlanceModifier) { + val fgColor = GlanceTheme.colors.onPrimaryContainer + val size = LocalSize.current + when { + size.height >= Sizes.short -> Column(modifier) { + Text( + text = title, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = fgColor + ), + maxLines = 2, + ) + Text( + text = subtitle, + style = TextStyle(fontSize = 14.sp, color = fgColor), + maxLines = 2, + ) + } + else -> Column(modifier) { + Text( + text = title, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = fgColor + ), + maxLines = 1, + ) + } + } +} + +@Composable +private fun PlayPauseButton( + modifier: GlanceModifier = GlanceModifier.size(Sizes.normal), + state: PlayPauseIcon, + onClick: () -> Unit +) { + val (iconRes: Int, description: Int) = when (state) { + PlayPauseIcon.Play -> R.drawable.outline_play_arrow_24 to R.string.content_description_play + PlayPauseIcon.Pause -> R.drawable.outline_pause_24 to R.string.content_description_pause + } + + val provider = ImageProvider(iconRes) + val contentDescription = LocalContext.current.getString(description) + + SquareIconButton( + modifier = modifier, + imageProvider = provider, + contentDescription = contentDescription, + onClick = onClick + ) +} + +enum class PlayPauseIcon { Play, Pause } + +/** + * Uses Coil to load images. + */ +@Composable +private fun WidgetAsyncImage( + uri: Uri, + contentDescription: String?, + modifier: GlanceModifier = GlanceModifier +) { + var bitmap by remember { mutableStateOf<Bitmap?>(null) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LaunchedEffect(key1 = uri) { + val request = ImageRequest.Builder(context) + .data(uri) + .size(200, 200) + .target { data: Drawable -> + bitmap = (data as BitmapDrawable).bitmap + } + .build() + + scope.launch(Dispatchers.IO) { + val result = ImageLoader(context).execute(request) + if (result is ErrorResult) { + val t = result.throwable + Log.e(TAG, "Image request error:", t) + } + } + } + + bitmap?.let { bitmap -> + Image( + provider = ImageProvider(bitmap), + contentDescription = contentDescription, + contentScale = ContentScale.FillBounds, + modifier = modifier.cornerRadius(12.dp) // TODO: confirm radius with design + ) + } +} diff --git a/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidgetPreview.kt b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidgetPreview.kt new file mode 100644 index 0000000000..8e26736f5b --- /dev/null +++ b/Jetcaster/glancewidget/src/main/java/com/example/jetcaster/glancewidget/JetcasterAppWidgetPreview.kt @@ -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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.glancewidget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProviderInfo +import android.content.ComponentName +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.SquareIconButton +import androidx.glance.appwidget.compose +import androidx.glance.appwidget.provideContent +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.size +import androidx.glance.layout.wrapContentSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +private object SizesPreview { + val medium = 56.dp +} + +/** + * This is a convenience function for updating the widget preview using Generated Previews. + * + * In a real application, this would be called whenever the widget's state changes. + */ +fun updateWidgetPreview(context: Context) { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + CoroutineScope(Dispatchers.IO).launch { + try { + val appwidgetManager = AppWidgetManager.getInstance(context) + + appwidgetManager.setWidgetPreview( + ComponentName(context, JetcasterAppWidgetReceiver::class.java), + AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN, + JetcasterAppWidgetPreview().compose( + context, + size = DpSize(160.dp, 64.dp) + ), + ) + } catch (e: Exception) { + Log.e(TAG, e.message, e) + } + } + } +} + +class JetcasterAppWidgetPreview : GlanceAppWidget() { + override val sizeMode: SizeMode + get() = SizeMode.Exact + + override suspend fun provideGlance(context: Context, id: GlanceId) { + + provideContent { + GlanceTheme { + Widget() + } + } + } +} + +@Composable +private fun Widget() { + + Scaffold { + Row( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.Vertical.CenterVertically + ) { + Image( + modifier = GlanceModifier.wrapContentSize().size(SizesPreview.medium), + provider = ImageProvider(R.drawable.widget_preview_thumbnail), + contentDescription = "" + ) + Spacer(GlanceModifier.defaultWeight()) + SquareIconButton( + modifier = GlanceModifier.size(SizesPreview.medium), + imageProvider = ImageProvider(R.drawable.outline_play_arrow_24), + contentDescription = "", + onClick = { } + ) + } + } +} diff --git a/Jetcaster/glancewidget/src/main/res/drawable/outline_pause_24.xml b/Jetcaster/glancewidget/src/main/res/drawable/outline_pause_24.xml new file mode 100644 index 0000000000..9b16bde427 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/drawable/outline_pause_24.xml @@ -0,0 +1,5 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp"> + + <path android:fillColor="@android:color/white" android:pathData="M520,760L520,200L760,200L760,760L520,760ZM200,760L200,200L440,200L440,760L200,760ZM600,680L680,680L680,280L600,280L600,680ZM280,680L360,680L360,280L280,280L280,680ZM280,280L280,280L280,680L280,680L280,280ZM600,280L600,280L600,680L600,680L600,280Z"/> + +</vector> diff --git a/Jetcaster/glancewidget/src/main/res/drawable/outline_play_arrow_24.xml b/Jetcaster/glancewidget/src/main/res/drawable/outline_play_arrow_24.xml new file mode 100644 index 0000000000..3588d6f062 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/drawable/outline_play_arrow_24.xml @@ -0,0 +1,5 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp"> + + <path android:fillColor="@android:color/white" android:pathData="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z"/> + +</vector> diff --git a/Jetcaster/glancewidget/src/main/res/drawable/outline_skip_next_24.xml b/Jetcaster/glancewidget/src/main/res/drawable/outline_skip_next_24.xml new file mode 100644 index 0000000000..a5b6207c91 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/drawable/outline_skip_next_24.xml @@ -0,0 +1,5 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp"> + + <path android:fillColor="@android:color/white" android:pathData="M660,720L660,240L740,240L740,720L660,720ZM220,720L220,240L580,480L220,720ZM300,480L300,480L300,480L300,480ZM300,570L436,480L300,390L300,570Z"/> + +</vector> diff --git a/Jetcaster/glancewidget/src/main/res/drawable/widget_preview.png b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview.png new file mode 100644 index 0000000000..eacc0ff4d7 Binary files /dev/null and b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview.png differ diff --git a/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_image_shape.xml b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_image_shape.xml new file mode 100644 index 0000000000..24dcb456d9 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_image_shape.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 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. + --> + +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <corners android:radius="16dp"/> +</shape> diff --git a/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_thumbnail.png b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_thumbnail.png new file mode 100644 index 0000000000..0a33505bc8 Binary files /dev/null and b/Jetcaster/glancewidget/src/main/res/drawable/widget_preview_thumbnail.png differ diff --git a/Jetcaster/glancewidget/src/main/res/layout/widget_preview.xml b/Jetcaster/glancewidget/src/main/res/layout/widget_preview.xml new file mode 100644 index 0000000000..8053af954f --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/layout/widget_preview.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- This file provides an XML preview layout for the widget. This file enables dynamic color +and dark mode support in Widget previews between android 12 and 15. Android 15+ uses Generated Previews. +--> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@android:id/background" + android:background="@color/colorAppWidgetBackground" + android:theme="@style/MyWidgetTheme" + > + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:padding="12dp" + android:layout_gravity="center_vertical" + + android:orientation="horizontal"> + <RelativeLayout + android:layout_width="@dimen/widget_preview_icon_height" + android:layout_height="@dimen/widget_preview_icon_height" + android:background="@drawable/widget_preview_image_shape" + android:clipToOutline="true" + android:layout_gravity="left|center_vertical" + android:outlineProvider="background"> + <ImageView + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scaleType="centerInside" + android:src="@drawable/widget_preview_thumbnail"/> + </RelativeLayout> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_weight="1"/> + <RelativeLayout + android:layout_width="@dimen/widget_preview_icon_height" + android:layout_height="@dimen/widget_preview_icon_height" + android:background="@drawable/widget_preview_image_shape" + android:clipToOutline="true" + android:layout_gravity="right|center_vertical" + + android:outlineProvider="background"> + <ImageView + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?attr/colorPrimary" + android:padding="8dp" + android:scaleType="centerInside" + android:src="@drawable/outline_play_arrow_24" + /> + </RelativeLayout> + </LinearLayout> +</FrameLayout> \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/res/values-h48dp/sizes.xml b/Jetcaster/glancewidget/src/main/res/values-h48dp/sizes.xml new file mode 100644 index 0000000000..f9321a0331 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-h48dp/sizes.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="widget_preview_icon_height">58dp</dimen> +</resources> \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/res/values-night-v31/colors.xml b/Jetcaster/glancewidget/src/main/res/values-night-v31/colors.xml new file mode 100644 index 0000000000..8348815e90 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-night-v31/colors.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 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. + --> + +<resources> + <color name="colorAppWidgetBackground">@android:color/system_accent2_800</color> +</resources> diff --git a/Jetcaster/glancewidget/src/main/res/values-night/colors.xml b/Jetcaster/glancewidget/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..3e00c7293e --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-night/colors.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 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. + --> + +<resources> + <color name="colorAppWidgetBackground">#ff20333d</color> +</resources> diff --git a/Jetcaster/glancewidget/src/main/res/values-v31/colors.xml b/Jetcaster/glancewidget/src/main/res/values-v31/colors.xml new file mode 100644 index 0000000000..b9536a2ec5 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-v31/colors.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 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. + --> + +<resources> + <color name="colorAppWidgetBackground">@android:color/system_accent2_50</color> +</resources> diff --git a/Jetcaster/glancewidget/src/main/res/values-v31/styles.xml b/Jetcaster/glancewidget/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000000..7f5c58270b --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values-v31/styles.xml @@ -0,0 +1,22 @@ +<!-- + ~ Copyright (C) 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. + --> +<resources> + <!-- Do not override any color attribute. --> + <style name="MyWidgetTheme" parent="Theme.Material3.DynamicColors.DayNight" > + </style> + +</resources> + diff --git a/Jetcaster/glancewidget/src/main/res/values/colors.xml b/Jetcaster/glancewidget/src/main/res/values/colors.xml new file mode 100644 index 0000000000..b545d6af07 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values/colors.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 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. + --> + +<resources> + + <color name="light_purple">#FFECDCFF</color> + <color name="aqua">#FF7CD7BA</color> + <color name="dark_gray">#FF2C322F</color> + <color name="colorAppWidgetBackground">#ffe0f3ff</color> +</resources> diff --git a/Jetcaster/glancewidget/src/main/res/values/sizes.xml b/Jetcaster/glancewidget/src/main/res/values/sizes.xml new file mode 100644 index 0000000000..0cda911ac9 --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values/sizes.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="widget_preview_icon_height">80dp</dimen> +</resources> \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/res/values/strings.xml b/Jetcaster/glancewidget/src/main/res/values/strings.xml new file mode 100644 index 0000000000..8248fa8d0d --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ +<resources> + <string name="app_widget_description">Play your podcasts</string> + <string name="content_description_play">Play</string> + <string name="content_description_pause">Pause</string> +</resources> \ No newline at end of file diff --git a/Jetcaster/glancewidget/src/main/res/values/styles.xml b/Jetcaster/glancewidget/src/main/res/values/styles.xml new file mode 100644 index 0000000000..eb4c694eed --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ +<!-- + ~ Copyright (C) 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. + --> + +<resources> + <style name="MyWidgetTheme" parent="Theme.Material3.DynamicColors.DayNight"> + <!-- Override default colorBackground attribute with custom color. --> + <item name="colorSurface">@color/light_purple</item> + </style> +</resources> diff --git a/Jetcaster/glancewidget/src/main/res/xml/jetcaster_info.xml b/Jetcaster/glancewidget/src/main/res/xml/jetcaster_info.xml new file mode 100644 index 0000000000..6391582b2d --- /dev/null +++ b/Jetcaster/glancewidget/src/main/res/xml/jetcaster_info.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" + android:description="@string/app_widget_description" + android:minWidth="140dp" + android:minHeight="48dp" + android:minResizeWidth="140dp" + android:minResizeHeight="48dp" + android:maxResizeWidth="520dp" + android:maxResizeHeight="64dp" + android:resizeMode="horizontal" + android:initialLayout="@layout/glance_default_loading_layout" + android:previewImage="@drawable/widget_preview" + android:previewLayout="@layout/widget_preview" + android:targetCellWidth="2" + android:targetCellHeight="1" + android:updatePeriodMillis="86400000" + android:widgetCategory="home_screen" /> \ No newline at end of file diff --git a/Jetcaster/gradle.properties b/Jetcaster/gradle.properties index 18038533b3..646f68d67b 100644 --- a/Jetcaster/gradle.properties +++ b/Jetcaster/gradle.properties @@ -37,6 +37,3 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml new file mode 100644 index 0000000000..29943df2e6 --- /dev/null +++ b/Jetcaster/gradle/libs.versions.toml @@ -0,0 +1,177 @@ +##### +# This file is duplicated to individual samples from the global scripts/libs.versions.toml +# Do not add a dependency to an individual sample, edit the global version instead. +##### +[versions] +accompanist = "0.37.2" +android-material3 = "1.13.0-alpha13" +androidGradlePlugin = "8.9.2" +androidx-activity-compose = "1.10.1" +androidx-appcompat = "1.7.0" +androidx-compose-bom = "2025.04.01" +androidx-constraintlayout = "1.1.0" +androidx-core-splashscreen = "1.0.1" +androidx-corektx = "1.16.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.9" +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.2" +google-maps = "18.2.0" +gradle-versions = "0.52.0" +hilt = "2.56.2" +hiltExt = "1.2.0" +horologist = "0.6.23" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.1.20" +kotlinx-serialization-json = "1.7.3" +kotlinx_immutable = "0.3.8" +ksp = "2.1.20-2.0.0" +maps-compose = "3.1.1" +# @keep +minSdk = "21" +okhttp = "4.12.0" +play-services-wearable = "18.1.0" +robolectric = "4.14.1" +roborazzi = "1.43.1" +rome = "2.1.0" +room = "2.7.1" +secrets = "2.0.1" +spotless = "7.0.3" +# @keep +targetSdk = "33" +version-catalog-update = "1.0.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = "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" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/Jetcaster/gradle/wrapper/gradle-wrapper.jar b/Jetcaster/gradle/wrapper/gradle-wrapper.jar index f6b961fd5a..7454180f2a 100644 Binary files a/Jetcaster/gradle/wrapper/gradle-wrapper.jar and b/Jetcaster/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Jetcaster/gradle/wrapper/gradle-wrapper.properties b/Jetcaster/gradle/wrapper/gradle-wrapper.properties index 99338ec765..d6c8bc7bf8 100644 --- a/Jetcaster/gradle/wrapper/gradle-wrapper.properties +++ b/Jetcaster/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,19 @@ -#Mon Dec 14 17:32:45 GMT 2020 +# 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 -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-rc-1-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Jetcaster/gradlew b/Jetcaster/gradlew index cccdd3d517..744e882ed5 100755 --- a/Jetcaster/gradlew +++ b/Jetcaster/gradlew @@ -1,5 +1,21 @@ #!/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 @@ -28,7 +44,7 @@ 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="" +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" @@ -56,7 +72,7 @@ case "`uname`" in Darwin* ) darwin=true ;; - MINGW* ) + MSYS* | MINGW* ) msys=true ;; NONSTOP* ) @@ -66,6 +82,7 @@ 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 @@ -109,10 +126,11 @@ if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then +# 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 @@ -138,19 +156,19 @@ if $cygwin ; then else eval `echo args$i`="\"$arg\"" fi - i=$((i+1)) + 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" ;; + 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 @@ -159,14 +177,9 @@ save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } -APP_ARGS=$(save "$@") +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" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - exec "$JAVACMD" "$@" diff --git a/Jetcaster/gradlew.bat b/Jetcaster/gradlew.bat index f9553162f1..ac1b06f938 100644 --- a/Jetcaster/gradlew.bat +++ b/Jetcaster/gradlew.bat @@ -1,84 +1,89 @@ -@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 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= - -@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 init - -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 init - -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 - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -: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 %CMD_LINE_ARGS% - -: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 +@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/Jetcaster/mobile/build.gradle.kts b/Jetcaster/mobile/build.gradle.kts new file mode 100644 index 0000000000..044fc24423 --- /dev/null +++ b/Jetcaster/mobile/build.gradle.kts @@ -0,0 +1,142 @@ +/* + * 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.ksp) + alias(libs.plugins.hilt) + alias(libs.plugins.compose) +} + + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "com.example.jetcaster" + + defaultConfig { + applicationId = "com.example.jetcaster" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + // Important: change the keystore for a production deployment + val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore") + val localKeystore = rootProject.file("debug_2.keystore") + val hasKeyInfo = userKeystore.exists() + create("release") { + // get from env variables + storeFile = if (hasKeyInfo) userKeystore else localKeystore + storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password") + keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias") + keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password") + } + } + + buildTypes { + getByName("debug") { + + } + + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging.resources { + // The Rome library JARs embed some internal utils libraries in nested JARs. + // We don't need them so we exclude them in the final package. + excludes += "/*.jar" + + // Multiple dependency bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } +} +kotlin { + jvmToolchain(17) +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.collections.immutable) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.palette) + + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Compose + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive) + implementation(libs.androidx.compose.material3.adaptive.layout) + implementation(libs.androidx.compose.material3.adaptive.navigation) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + + implementation(libs.androidx.window) + implementation(libs.androidx.window.core) + + implementation(libs.accompanist.adaptive) + + implementation(libs.coil.kt.compose) + + implementation(projects.core.data) + implementation(projects.core.designsystem) + implementation(projects.core.domain) + implementation(projects.glancewidget) + implementation(projects.core.domainTesting) + + coreLibraryDesugaring(libs.core.jdk.desugaring) +} diff --git a/Jetcaster/mobile/proguard-rules.pro b/Jetcaster/mobile/proguard-rules.pro new file mode 100644 index 0000000000..8bba6b5e9c --- /dev/null +++ b/Jetcaster/mobile/proguard-rules.pro @@ -0,0 +1,52 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses + +# Rome reflectively loads classes referenced in com/rometools/rome/rome.properties. +-adaptresourcefilecontents com/rometools/rome/rome.properties +-keep class * implements com.rometools.rome.feed.synd.Converter +-keep class * implements com.rometools.rome.io.ModuleParser +-keep class * implements com.rometools.rome.io.WireFeedParser + +# Disable warnings for missing classes from OkHttp. +-dontwarn org.conscrypt.ConscryptHostnameVerifier + +# Disable warnings for missing classes from JDOM. +-dontwarn org.jaxen.DefaultNavigator +-dontwarn org.jaxen.NamespaceContext +-dontwarn org.jaxen.VariableContext + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE + +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } \ No newline at end of file diff --git a/Jetcaster/mobile/src/main/AndroidManifest.xml b/Jetcaster/mobile/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..d2f05d870a --- /dev/null +++ b/Jetcaster/mobile/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <!-- Uses ACCESS_NETWORK_STATE to check if the device is connected to internet or not --> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <!-- Uses INTERNET to fetch RSS feed + images --> + <uses-permission android:name="android.permission.INTERNET" /> + + <application + android:name=".JetcasterApplication" + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/Theme.Jetcaster" + android:enableOnBackInvokedCallback="true" + android:usesCleartextTraffic="true"> + + <activity + android:name="com.example.jetcaster.ui.MainActivity" + android:label="@string/app_name" + android:theme="@style/Theme.Jetcaster" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + + </application> + +</manifest> diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/JetcasterApplication.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/JetcasterApplication.kt new file mode 100644 index 0000000000..120187d2d5 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/JetcasterApplication.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster + +import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +/** + * Application which sets up our dependency [Graph] with a context. + */ +@HiltAndroidApp +class JetcasterApplication : Application(), ImageLoaderFactory { + + @Inject lateinit var imageLoader: ImageLoader + + override fun newImageLoader(): ImageLoader = imageLoader +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt new file mode 100644 index 0000000000..9d22c05fbf --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.scaleOut +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.window.layout.DisplayFeature +import com.example.jetcaster.R +import com.example.jetcaster.ui.home.MainScreen +import com.example.jetcaster.ui.player.PlayerScreen + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun JetcasterApp( + displayFeatures: List<DisplayFeature>, + appState: JetcasterAppState = rememberJetcasterAppState() +) { + val adaptiveInfo = currentWindowAdaptiveInfo() + if (appState.isOnline) { + NavHost( + navController = appState.navController, + startDestination = Screen.Home.route, + popExitTransition = { scaleOut(targetScale = 0.9f) }, + popEnterTransition = { EnterTransition.None } + ) { + composable(Screen.Home.route) { backStackEntry -> + MainScreen( + windowSizeClass = adaptiveInfo.windowSizeClass, + navigateToPlayer = { episode -> + appState.navigateToPlayer(episode.uri, backStackEntry) + } + ) + } + composable(Screen.Player.route) { + PlayerScreen( + windowSizeClass = adaptiveInfo.windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = appState::navigateBack + ) + } + } + } else { + OfflineDialog { appState.refreshOnline() } + } +} + +@Composable +fun OfflineDialog(onRetry: () -> Unit) { + AlertDialog( + onDismissRequest = {}, + title = { Text(text = stringResource(R.string.connection_error_title)) }, + text = { Text(text = stringResource(R.string.connection_error_message)) }, + confirmButton = { + TextButton(onClick = onRetry) { + Text(stringResource(R.string.retry_label)) + } + } + ) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt new file mode 100644 index 0000000000..ee938066a7 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/JetcasterAppState.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat.getSystemService +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController + +/** + * List of screens for [JetcasterApp] + */ +sealed class Screen(val route: String) { + object Home : Screen("home") + object Player : Screen("player/{$ARG_EPISODE_URI}") { + fun createRoute(episodeUri: String) = "player/$episodeUri" + } + + object PodcastDetails : Screen("podcast/{$ARG_PODCAST_URI}") { + + val PODCAST_URI = "podcastUri" + fun createRoute(podcastUri: String) = "podcast/$podcastUri" + } + + companion object { + val ARG_PODCAST_URI = "podcastUri" + val ARG_EPISODE_URI = "episodeUri" + } +} + +@Composable +fun rememberJetcasterAppState( + navController: NavHostController = rememberNavController(), + context: Context = LocalContext.current +) = remember(navController, context) { + JetcasterAppState(navController, context) +} + +class JetcasterAppState( + val navController: NavHostController, + private val context: Context +) { + var isOnline by mutableStateOf(checkIfOnline()) + private set + + fun refreshOnline() { + isOnline = checkIfOnline() + } + + fun navigateToPlayer(episodeUri: String, from: NavBackStackEntry) { + // In order to discard duplicated navigation events, we check the Lifecycle + if (from.lifecycleIsResumed()) { + val encodedUri = Uri.encode(episodeUri) + navController.navigate(Screen.Player.createRoute(encodedUri)) + } + } + + fun navigateToPodcastDetails(podcastUri: String, from: NavBackStackEntry) { + if (from.lifecycleIsResumed()) { + val encodedUri = Uri.encode(podcastUri) + navController.navigate(Screen.PodcastDetails.createRoute(encodedUri)) + } + } + + fun navigateBack() { + navController.popBackStack() + } + + @Suppress("DEPRECATION") + private fun checkIfOnline(): Boolean { + val cm = getSystemService(context, ConnectivityManager::class.java) + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val capabilities = cm?.getNetworkCapabilities(cm.activeNetwork) ?: return false + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + cm?.activeNetworkInfo?.isConnectedOrConnecting == true + } + } +} + +/** + * If the lifecycle is not resumed it means this NavBackStackEntry already processed a nav event. + * + * This is used to de-duplicate navigation events. + */ +private fun NavBackStackEntry.lifecycleIsResumed() = + this.lifecycle.currentState == Lifecycle.State.RESUMED diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/MainActivity.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/MainActivity.kt new file mode 100644 index 0000000000..c038c5d506 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/MainActivity.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.example.jetcaster.glancewidget.updateWidgetPreview +import com.example.jetcaster.ui.theme.JetcasterTheme +import com.google.accompanist.adaptive.calculateDisplayFeatures +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + updateWidgetPreview(this) + setContent { + val displayFeatures = calculateDisplayFeatures(this) + + JetcasterTheme { + JetcasterApp( + displayFeatures + ) + } + } + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt new file mode 100644 index 0000000000..fe597ccc48 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -0,0 +1,813 @@ +/* + * Copyright 2020-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalFoundationApi::class) + +package com.example.jetcaster.ui.home + +import androidx.activity.compose.BackHandler +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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabPosition +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.Posture +import androidx.compose.material3.adaptive.WindowAdaptiveInfo +import androidx.compose.material3.adaptive.allVerticalHingeBounds +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.HingePolicy +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator +import androidx.compose.material3.adaptive.occludingVerticalHingeBounds +import androidx.compose.material3.adaptive.separatingVerticalHingeBounds +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass +import com.example.jetcaster.R +import com.example.jetcaster.core.domain.testing.PreviewCategories +import com.example.jetcaster.core.domain.testing.PreviewPodcastEpisodes +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.LibraryInfo +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.ui.home.discover.discoverItems +import com.example.jetcaster.ui.home.library.libraryItems +import com.example.jetcaster.ui.podcast.PodcastDetailsScreen +import com.example.jetcaster.ui.podcast.PodcastDetailsViewModel +import com.example.jetcaster.ui.theme.JetcasterTheme +import com.example.jetcaster.ui.tooling.DevicePreviews +import com.example.jetcaster.util.ToggleFollowPodcastIconButton +import com.example.jetcaster.util.fullWidthItem +import com.example.jetcaster.util.isCompact +import com.example.jetcaster.util.quantityStringResource +import com.example.jetcaster.util.radialGradientScrim +import java.time.Duration +import java.time.LocalDateTime +import java.time.OffsetDateTime +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun <T> ThreePaneScaffoldNavigator<T>.isMainPaneHidden(): Boolean = + scaffoldValue[SupportingPaneScaffoldRole.Main] == PaneAdaptedValue.Hidden + +/** + * Copied from `calculatePaneScaffoldDirective()` in [PaneScaffoldDirective], with modifications to + * only show 1 pane horizontally if either width or height size class is compact. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +fun calculateScaffoldDirective( + windowAdaptiveInfo: WindowAdaptiveInfo, + verticalHingePolicy: HingePolicy = HingePolicy.AvoidSeparating, +): PaneScaffoldDirective { + val maxHorizontalPartitions: Int + val verticalSpacerSize: Dp + if (windowAdaptiveInfo.windowSizeClass.isCompact) { + // Window width or height is compact. Limit to 1 pane horizontally. + maxHorizontalPartitions = 1 + verticalSpacerSize = 0.dp + } else { + when (windowAdaptiveInfo.windowSizeClass.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> { + maxHorizontalPartitions = 1 + verticalSpacerSize = 0.dp + } + + WindowWidthSizeClass.MEDIUM -> { + maxHorizontalPartitions = 1 + verticalSpacerSize = 0.dp + } + + else -> { + maxHorizontalPartitions = 2 + verticalSpacerSize = 24.dp + } + } + } + val maxVerticalPartitions: Int + val horizontalSpacerSize: Dp + + if (windowAdaptiveInfo.windowPosture.isTabletop) { + maxVerticalPartitions = 2 + horizontalSpacerSize = 24.dp + } else { + maxVerticalPartitions = 1 + horizontalSpacerSize = 0.dp + } + + val defaultPanePreferredWidth = 360.dp + + return PaneScaffoldDirective( + maxHorizontalPartitions, + verticalSpacerSize, + maxVerticalPartitions, + horizontalSpacerSize, + defaultPanePreferredWidth, + getExcludedVerticalBounds(windowAdaptiveInfo.windowPosture, verticalHingePolicy) + ) +} + +/** + * Copied from `getExcludedVerticalBounds()` in [PaneScaffoldDirective] since it is private. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun getExcludedVerticalBounds(posture: Posture, hingePolicy: HingePolicy): List<Rect> = + when (hingePolicy) { + HingePolicy.AvoidSeparating -> posture.separatingVerticalHingeBounds + HingePolicy.AvoidOccluding -> posture.occludingVerticalHingeBounds + HingePolicy.AlwaysAvoid -> posture.allVerticalHingeBounds + else -> emptyList() + } + +@Composable +fun MainScreen( + windowSizeClass: WindowSizeClass, + navigateToPlayer: (EpisodeInfo) -> Unit, + viewModel: HomeViewModel = hiltViewModel(), +) { + val homeScreenUiState by viewModel.state.collectAsStateWithLifecycle() + val uiState = homeScreenUiState + Box { + HomeScreenReady( + uiState = uiState, + windowSizeClass = windowSizeClass, + navigateToPlayer = navigateToPlayer, + viewModel = viewModel + ) + + if (uiState.errorMessage != null) { + HomeScreenError(onRetry = viewModel::refresh) + } + } +} + +@Composable +private fun HomeScreenError(onRetry: () -> Unit, modifier: Modifier = Modifier) { + Surface(modifier = modifier) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + Text( + text = stringResource(id = R.string.an_error_has_occurred), + modifier = Modifier.padding(16.dp) + ) + Button(onClick = onRetry) { + Text(text = stringResource(id = R.string.retry_label)) + } + } + } +} + +@Preview +@Composable +fun HomeScreenErrorPreview() { + JetcasterTheme { + HomeScreenError(onRetry = {}) + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun HomeScreenReady( + uiState: HomeScreenUiState, + windowSizeClass: WindowSizeClass, + navigateToPlayer: (EpisodeInfo) -> Unit, + viewModel: HomeViewModel = hiltViewModel(), +) { + val navigator = rememberSupportingPaneScaffoldNavigator<String>( + scaffoldDirective = calculateScaffoldDirective(currentWindowAdaptiveInfo()) + ) + + val scope = rememberCoroutineScope() + + BackHandler(enabled = navigator.canNavigateBack()) { + scope.launch { navigator.navigateBack() } + } + + Surface { + SupportingPaneScaffold( + value = navigator.scaffoldValue, + directive = navigator.scaffoldDirective, + mainPane = { + HomeScreen( + windowSizeClass = windowSizeClass, + isLoading = uiState.isLoading, + featuredPodcasts = uiState.featuredPodcasts, + homeCategories = uiState.homeCategories, + selectedHomeCategory = uiState.selectedHomeCategory, + filterableCategoriesModel = uiState.filterableCategoriesModel, + podcastCategoryFilterResult = uiState.podcastCategoryFilterResult, + library = uiState.library, + onHomeAction = viewModel::onHomeAction, + navigateToPodcastDetails = { + scope.launch { + navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, it.uri) + } + }, + navigateToPlayer = navigateToPlayer, + modifier = Modifier.fillMaxSize() + ) + }, + supportingPane = { + val podcastUri = navigator.currentDestination?.contentKey + if (!podcastUri.isNullOrEmpty()) { + val podcastDetailsViewModel = + hiltViewModel<PodcastDetailsViewModel, PodcastDetailsViewModel.Factory>( + key = podcastUri + ) { + it.create(podcastUri) + } + PodcastDetailsScreen( + viewModel = podcastDetailsViewModel, + navigateToPlayer = navigateToPlayer, + navigateBack = { + if (navigator.canNavigateBack()) { + scope.launch { + navigator.navigateBack() + } + } + }, + showBackButton = navigator.isMainPaneHidden() + ) + } + }, + modifier = Modifier.fillMaxSize() + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeAppBar(isExpanded: Boolean, modifier: Modifier = Modifier) { + var queryText by remember { + mutableStateOf("") + } + Row( + horizontalArrangement = Arrangement.End, + modifier = modifier + .fillMaxWidth() + .background(Color.Transparent) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = queryText, + onQueryChange = { queryText = it }, + onSearch = {}, + expanded = false, + onExpandedChange = {}, + enabled = true, + placeholder = { + Text(stringResource(id = R.string.search_for_a_podcast)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = stringResource(R.string.cd_account) + ) + }, + interactionSource = null, + modifier = if (isExpanded) Modifier.fillMaxWidth() else Modifier + ) + }, + expanded = false, + onExpandedChange = {} + ) {} + } +} + +@Composable +private fun HomeScreenBackground( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.background) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .radialGradientScrim(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)) + ) + content() + } +} + +@Composable +private fun HomeScreen( + windowSizeClass: WindowSizeClass, + isLoading: Boolean, + featuredPodcasts: PersistentList<PodcastInfo>, + selectedHomeCategory: HomeCategory, + homeCategories: List<HomeCategory>, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + library: LibraryInfo, + onHomeAction: (HomeAction) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + modifier: Modifier = Modifier, +) { + // Effect that changes the home category selection when there are no subscribed podcasts + LaunchedEffect(key1 = featuredPodcasts) { + if (featuredPodcasts.isEmpty()) { + onHomeAction(HomeAction.HomeCategorySelected(HomeCategory.Discover)) + } + } + + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + HomeScreenBackground( + modifier = modifier.windowInsetsPadding(WindowInsets.navigationBars) + ) { + Scaffold( + topBar = { + Column { + HomeAppBar( + isExpanded = windowSizeClass.isCompact, + modifier = Modifier.fillMaxWidth() + ) + if (isLoading) { + LinearProgressIndicator( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + containerColor = Color.Transparent + ) { contentPadding -> + // Main Content + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + val showHomeCategoryTabs = featuredPodcasts.isNotEmpty() && homeCategories.isNotEmpty() + HomeContent( + showHomeCategoryTabs = showHomeCategoryTabs, + featuredPodcasts = featuredPodcasts, + selectedHomeCategory = selectedHomeCategory, + homeCategories = homeCategories, + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + library = library, + modifier = Modifier.padding(contentPadding), + onHomeAction = { action -> + if (action is HomeAction.QueueEpisode) { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + } + onHomeAction(action) + }, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer + ) + } + } +} + +@Composable +private fun HomeContent( + showHomeCategoryTabs: Boolean, + featuredPodcasts: PersistentList<PodcastInfo>, + selectedHomeCategory: HomeCategory, + homeCategories: List<HomeCategory>, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + library: LibraryInfo, + modifier: Modifier = Modifier, + onHomeAction: (HomeAction) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, +) { + val pagerState = rememberPagerState { featuredPodcasts.size } + LaunchedEffect(pagerState, featuredPodcasts) { + snapshotFlow { pagerState.currentPage } + .collect { + val podcast = featuredPodcasts.getOrNull(it) + onHomeAction(HomeAction.LibraryPodcastSelected(podcast)) + } + } + + HomeContentGrid( + pagerState = pagerState, + showHomeCategoryTabs = showHomeCategoryTabs, + featuredPodcasts = featuredPodcasts, + selectedHomeCategory = selectedHomeCategory, + homeCategories = homeCategories, + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + library = library, + modifier = modifier, + onHomeAction = onHomeAction, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer + ) +} + +@Composable +private fun HomeContentGrid( + showHomeCategoryTabs: Boolean, + pagerState: PagerState, + featuredPodcasts: PersistentList<PodcastInfo>, + selectedHomeCategory: HomeCategory, + homeCategories: List<HomeCategory>, + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + library: LibraryInfo, + modifier: Modifier = Modifier, + onHomeAction: (HomeAction) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(362.dp), + modifier = modifier.fillMaxSize() + ) { + if (featuredPodcasts.isNotEmpty()) { + fullWidthItem { + FollowedPodcastItem( + pagerState = pagerState, + items = featuredPodcasts, + onPodcastUnfollowed = { onHomeAction(HomeAction.PodcastUnfollowed(it)) }, + navigateToPodcastDetails = navigateToPodcastDetails, + modifier = Modifier + .fillMaxWidth() + ) + } + } + + if (showHomeCategoryTabs) { + fullWidthItem { + Row { + HomeCategoryTabs( + categories = homeCategories, + selectedCategory = selectedHomeCategory, + showHorizontalLine = false, + onCategorySelected = { onHomeAction(HomeAction.HomeCategorySelected(it)) }, + modifier = Modifier.width(240.dp) + ) + } + } + } + + when (selectedHomeCategory) { + HomeCategory.Library -> { + libraryItems( + library = library, + navigateToPlayer = navigateToPlayer, + onQueueEpisode = { onHomeAction(HomeAction.QueueEpisode(it)) } + ) + } + + HomeCategory.Discover -> { + discoverItems( + filterableCategoriesModel = filterableCategoriesModel, + podcastCategoryFilterResult = podcastCategoryFilterResult, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + onCategorySelected = { onHomeAction(HomeAction.CategorySelected(it)) }, + onTogglePodcastFollowed = { + onHomeAction(HomeAction.TogglePodcastFollowed(it)) + }, + onQueueEpisode = { onHomeAction(HomeAction.QueueEpisode(it)) } + ) + } + } + } +} + +@Composable +private fun FollowedPodcastItem( + pagerState: PagerState, + items: PersistentList<PodcastInfo>, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Spacer(Modifier.height(16.dp)) + + FollowedPodcasts( + pagerState = pagerState, + items = items, + onPodcastUnfollowed = onPodcastUnfollowed, + navigateToPodcastDetails = navigateToPodcastDetails, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun HomeCategoryTabs( + categories: List<HomeCategory>, + selectedCategory: HomeCategory, + onCategorySelected: (HomeCategory) -> Unit, + showHorizontalLine: Boolean, + modifier: Modifier = Modifier, +) { + if (categories.isEmpty()) { + return + } + + val selectedIndex = categories.indexOfFirst { it == selectedCategory } + val indicator = @Composable { tabPositions: List<TabPosition> -> + HomeCategoryTabIndicator( + Modifier.tabIndicatorOffset(tabPositions[selectedIndex]) + ) + } + + TabRow( + selectedTabIndex = selectedIndex, + containerColor = Color.Transparent, + indicator = indicator, + modifier = modifier, + divider = { + if (showHorizontalLine) { + HorizontalDivider() + } + } + ) { + categories.forEachIndexed { index, category -> + Tab( + selected = index == selectedIndex, + onClick = { onCategorySelected(category) }, + text = { + Text( + text = when (category) { + HomeCategory.Library -> stringResource(R.string.home_library) + HomeCategory.Discover -> stringResource(R.string.home_discover) + }, + style = MaterialTheme.typography.bodyMedium + ) + } + ) + } + } +} + +@Composable +private fun HomeCategoryTabIndicator( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface, +) { + Spacer( + modifier + .padding(horizontal = 24.dp) + .height(4.dp) + .background(color, RoundedCornerShape(topStartPercent = 100, topEndPercent = 100)) + ) +} + +private val FEATURED_PODCAST_IMAGE_SIZE_DP = 160.dp + +@Composable +private fun FollowedPodcasts( + pagerState: PagerState, + items: PersistentList<PodcastInfo>, + onPodcastUnfollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, +) { + // TODO: Using BoxWithConstraints is not quite performant since it requires 2 passes to compute + // the content padding. This should be revisited once a carousel component is available. + // Alternatively, version 1.7.0-alpha05 of Compose Foundation supports `snapPosition` + // which solves this problem and avoids this calculation altogether. Once 1.7.0 is + // stable, this implementation can be updated. + BoxWithConstraints( + modifier = modifier.background(Color.Transparent) + ) { + val horizontalPadding = (this.maxWidth - FEATURED_PODCAST_IMAGE_SIZE_DP) / 2 + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues( + horizontal = horizontalPadding, + vertical = 16.dp + ), + pageSpacing = 24.dp, + pageSize = PageSize.Fixed(FEATURED_PODCAST_IMAGE_SIZE_DP) + ) { page -> + val podcast = items[page] + FollowedPodcastCarouselItem( + podcastImageUrl = podcast.imageUrl, + podcastTitle = podcast.title, + onUnfollowedClick = { onPodcastUnfollowed(podcast) }, + lastEpisodeDateText = podcast.lastEpisodeDate?.let { lastUpdated(it) }, + modifier = Modifier + .fillMaxSize() + .clickable { + navigateToPodcastDetails(podcast) + } + ) + } + } +} + +@Composable +private fun FollowedPodcastCarouselItem( + podcastTitle: String, + podcastImageUrl: String, + modifier: Modifier = Modifier, + lastEpisodeDateText: String? = null, + onUnfollowedClick: () -> Unit, +) { + Column(modifier) { + Box( + Modifier + .size(FEATURED_PODCAST_IMAGE_SIZE_DP) + .align(Alignment.CenterHorizontally) + ) { + PodcastImage( + podcastImageUrl = podcastImageUrl, + contentDescription = podcastTitle, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium) + ) + + ToggleFollowPodcastIconButton( + onClick = onUnfollowedClick, + isFollowed = true, /* All podcasts are followed in this feed */ + modifier = Modifier.align(Alignment.BottomEnd) + ) + } + + if (lastEpisodeDateText != null) { + Text( + text = lastEpisodeDateText, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .align(Alignment.CenterHorizontally) + ) + } + } +} + +@Composable +private fun lastUpdated(updated: OffsetDateTime): String { + val duration = Duration.between(updated.toLocalDateTime(), LocalDateTime.now()) + val days = duration.toDays().toInt() + + return when { + days > 28 -> stringResource(R.string.updated_longer) + days >= 7 -> { + val weeks = days / 7 + quantityStringResource(R.plurals.updated_weeks_ago, weeks, weeks) + } + + days > 0 -> quantityStringResource(R.plurals.updated_days_ago, days, days) + else -> stringResource(R.string.updated_today) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun HomeAppBarPreview() { + JetcasterTheme { + HomeAppBar( + isExpanded = false + ) + } +} + +private val CompactWindowSizeClass = WindowSizeClass.compute(360f, 780f) + +@DevicePreviews +@Composable +private fun PreviewHome() { + JetcasterTheme { + HomeScreen( + windowSizeClass = CompactWindowSizeClass, + isLoading = true, + featuredPodcasts = PreviewPodcasts.toPersistentList(), + homeCategories = HomeCategory.entries, + selectedHomeCategory = HomeCategory.Discover, + filterableCategoriesModel = FilterableCategoriesModel( + categories = PreviewCategories, + selectedCategory = PreviewCategories.firstOrNull() + ), + podcastCategoryFilterResult = PodcastCategoryFilterResult( + topPodcasts = PreviewPodcasts, + episodes = PreviewPodcastEpisodes + ), + library = LibraryInfo(), + onHomeAction = {}, + navigateToPodcastDetails = {}, + navigateToPlayer = {} + ) + } +} + +@Composable +@Preview +private fun PreviewPodcastCard() { + JetcasterTheme { + FollowedPodcastCarouselItem( + modifier = Modifier.size(128.dp), + podcastTitle = "", + podcastImageUrl = "", + onUnfollowedClick = {} + ) + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt new file mode 100644 index 0000000000..f84b68cd32 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -0,0 +1,226 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.home + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.domain.FilterableCategoriesUseCase +import com.example.jetcaster.core.domain.PodcastCategoryFilterUseCase +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.LibraryInfo +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.model.asPodcastToEpisodeInfo +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class HomeViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val podcastStore: PodcastStore, + private val episodeStore: EpisodeStore, + private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase, + private val filterableCategoriesUseCase: FilterableCategoriesUseCase, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + // Holds our currently selected podcast in the library + private val selectedLibraryPodcast = MutableStateFlow<PodcastInfo?>(null) + + // Holds our currently selected home category + private val selectedHomeCategory = MutableStateFlow(HomeCategory.Discover) + + // Holds the currently available home categories + private val homeCategories = MutableStateFlow(HomeCategory.entries) + + // Holds our currently selected category + private val _selectedCategory = MutableStateFlow<CategoryInfo?>(null) + + // Holds our view state which the UI collects via [state] + private val _state = MutableStateFlow(HomeScreenUiState()) + + // Holds the view state if the UI is refreshing for new data + private val refreshing = MutableStateFlow(false) + + private val subscribedPodcasts = podcastStore.followedPodcastsSortedByLastEpisode(limit = 10) + .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) + + val state: StateFlow<HomeScreenUiState> + get() = _state + + init { + viewModelScope.launch { + // Combines the latest value from each of the flows, allowing us to generate a + // view state instance which only contains the latest values. + com.example.jetcaster.core.util.combine( + homeCategories, + selectedHomeCategory, + subscribedPodcasts, + refreshing, + _selectedCategory.flatMapLatest { selectedCategory -> + filterableCategoriesUseCase(selectedCategory) + }, + _selectedCategory.flatMapLatest { + podcastCategoryFilterUseCase(it) + }, + subscribedPodcasts.flatMapLatest { podcasts -> + episodeStore.episodesInPodcasts( + podcastUris = podcasts.map { it.podcast.uri }, + limit = 20 + ) + } + ) { homeCategories, + homeCategory, + podcasts, + refreshing, + filterableCategories, + podcastCategoryFilterResult, + libraryEpisodes -> + + _selectedCategory.value = filterableCategories.selectedCategory + + // Override selected home category to show 'DISCOVER' if there are no + // featured podcasts + selectedHomeCategory.value = + if (podcasts.isEmpty()) HomeCategory.Discover else homeCategory + + HomeScreenUiState( + isLoading = refreshing, + homeCategories = homeCategories, + selectedHomeCategory = homeCategory, + featuredPodcasts = podcasts.map { it.asExternalModel() }.toPersistentList(), + filterableCategoriesModel = filterableCategories, + podcastCategoryFilterResult = podcastCategoryFilterResult, + library = libraryEpisodes.asLibrary() + ) + }.catch { throwable -> + emit( + HomeScreenUiState( + isLoading = false, + errorMessage = throwable.message + ) + ) + }.collect { + _state.value = it + } + } + + refresh(force = false) + } + + fun refresh(force: Boolean = true) { + viewModelScope.launch { + runCatching { + refreshing.value = true + podcastsRepository.updatePodcasts(force) + } + // TODO: look at result of runCatching and show any errors + + refreshing.value = false + } + } + + fun onHomeAction(action: HomeAction) { + when (action) { + is HomeAction.CategorySelected -> onCategorySelected(action.category) + is HomeAction.HomeCategorySelected -> onHomeCategorySelected(action.category) + is HomeAction.LibraryPodcastSelected -> onLibraryPodcastSelected(action.podcast) + is HomeAction.PodcastUnfollowed -> onPodcastUnfollowed(action.podcast) + is HomeAction.QueueEpisode -> onQueueEpisode(action.episode) + is HomeAction.TogglePodcastFollowed -> onTogglePodcastFollowed(action.podcast) + } + } + + private fun onCategorySelected(category: CategoryInfo) { + _selectedCategory.value = category + } + + private fun onHomeCategorySelected(category: HomeCategory) { + selectedHomeCategory.value = category + } + + private fun onPodcastUnfollowed(podcast: PodcastInfo) { + viewModelScope.launch { + podcastStore.unfollowPodcast(podcast.uri) + } + } + + private fun onTogglePodcastFollowed(podcast: PodcastInfo) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + + private fun onLibraryPodcastSelected(podcast: PodcastInfo?) { + selectedLibraryPodcast.value = podcast + } + + private fun onQueueEpisode(episode: PlayerEpisode) { + episodePlayer.addToQueue(episode) + } +} + +private fun List<EpisodeToPodcast>.asLibrary(): LibraryInfo = + LibraryInfo( + episodes = this.map { it.asPodcastToEpisodeInfo() } + ) + +enum class HomeCategory { + Library, Discover +} + +@Immutable +sealed interface HomeAction { + data class CategorySelected(val category: CategoryInfo) : HomeAction + data class HomeCategorySelected(val category: HomeCategory) : HomeAction + data class PodcastUnfollowed(val podcast: PodcastInfo) : HomeAction + data class TogglePodcastFollowed(val podcast: PodcastInfo) : HomeAction + data class LibraryPodcastSelected(val podcast: PodcastInfo?) : HomeAction + data class QueueEpisode(val episode: PlayerEpisode) : HomeAction +} + +@Immutable +data class HomeScreenUiState( + val isLoading: Boolean = true, + val errorMessage: String? = null, + val featuredPodcasts: PersistentList<PodcastInfo> = persistentListOf(), + val selectedHomeCategory: HomeCategory = HomeCategory.Discover, + val homeCategories: List<HomeCategory> = emptyList(), + val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(), + val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(), + val library: LibraryInfo = LibraryInfo(), +) diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt new file mode 100644 index 0000000000..e1ab2bb7bf --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/category/PodcastCategory.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.home.category + +import androidx.compose.foundation.clickable +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +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.draw.clip +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetcaster.core.domain.testing.PreviewEpisodes +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.ui.shared.EpisodeListItem +import com.example.jetcaster.ui.theme.JetcasterTheme +import com.example.jetcaster.util.ToggleFollowPodcastIconButton +import com.example.jetcaster.util.fullWidthItem + +fun LazyGridScope.podcastCategory( + podcastCategoryFilterResult: PodcastCategoryFilterResult, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, +) { + fullWidthItem { + CategoryPodcasts( + topPodcasts = podcastCategoryFilterResult.topPodcasts, + navigateToPodcastDetails = navigateToPodcastDetails, + onTogglePodcastFollowed = onTogglePodcastFollowed + ) + } + + val episodes = podcastCategoryFilterResult.episodes + items(episodes, key = { it.episode.uri }) { item -> + EpisodeListItem( + episode = item.episode, + podcast = item.podcast, + onClick = navigateToPlayer, + onQueueEpisode = onQueueEpisode, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun CategoryPodcasts( + topPodcasts: List<PodcastInfo>, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit +) { + CategoryPodcastRow( + podcasts = topPodcasts, + onTogglePodcastFollowed = onTogglePodcastFollowed, + navigateToPodcastDetails = navigateToPodcastDetails, + modifier = Modifier.fillMaxWidth() + ) +} + +@Composable +private fun CategoryPodcastRow( + podcasts: List<PodcastInfo>, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier +) { + LazyRow( + modifier = modifier, + contentPadding = PaddingValues( + start = Keyline1, + top = 8.dp, + end = Keyline1, + bottom = 24.dp + ), + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + items( + items = podcasts, + key = { it.uri } + ) { podcast -> + TopPodcastRowItem( + podcastTitle = podcast.title, + podcastImageUrl = podcast.imageUrl, + isFollowed = podcast.isSubscribed ?: false, + onToggleFollowClicked = { onTogglePodcastFollowed(podcast) }, + modifier = Modifier + .width(128.dp) + .clickable { + navigateToPodcastDetails(podcast) + } + ) + } + } +} + +@Composable +private fun TopPodcastRowItem( + podcastTitle: String, + podcastImageUrl: String, + isFollowed: Boolean, + modifier: Modifier = Modifier, + onToggleFollowClicked: () -> Unit, +) { + Column( + modifier.semantics(mergeDescendants = true) {} + ) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .align(Alignment.CenterHorizontally) + ) { + PodcastImage( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium), + podcastImageUrl = podcastImageUrl, + contentDescription = podcastTitle + ) + + ToggleFollowPodcastIconButton( + onClick = onToggleFollowClicked, + isFollowed = isFollowed, + modifier = Modifier.align(Alignment.BottomEnd) + ) + } + + Text( + text = podcastTitle, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + ) + } +} + +@Preview +@Composable +fun PreviewEpisodeListItem() { + JetcasterTheme { + EpisodeListItem( + episode = PreviewEpisodes[0], + podcast = PreviewPodcasts[0], + onClick = { }, + onQueueEpisode = { }, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt new file mode 100644 index 0000000000..1bf0d6017e --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/discover/Discover.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.home.discover + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R +import com.example.jetcaster.core.model.CategoryInfo +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.FilterableCategoriesModel +import com.example.jetcaster.core.model.PodcastCategoryFilterResult +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.ui.home.category.podcastCategory +import com.example.jetcaster.util.fullWidthItem + +fun LazyGridScope.discoverItems( + filterableCategoriesModel: FilterableCategoriesModel, + podcastCategoryFilterResult: PodcastCategoryFilterResult, + navigateToPodcastDetails: (PodcastInfo) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + onCategorySelected: (CategoryInfo) -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, +) { + if (filterableCategoriesModel.isEmpty) { + // TODO: empty state + return + } + + fullWidthItem { + Spacer(Modifier.height(8.dp)) + + PodcastCategoryTabs( + filterableCategoriesModel = filterableCategoriesModel, + onCategorySelected = onCategorySelected, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(8.dp)) + } + + podcastCategory( + podcastCategoryFilterResult = podcastCategoryFilterResult, + navigateToPodcastDetails = navigateToPodcastDetails, + navigateToPlayer = navigateToPlayer, + onTogglePodcastFollowed = onTogglePodcastFollowed, + onQueueEpisode = onQueueEpisode, + ) +} + +@Composable +private fun PodcastCategoryTabs( + filterableCategoriesModel: FilterableCategoriesModel, + onCategorySelected: (CategoryInfo) -> Unit, + modifier: Modifier = Modifier +) { + val selectedIndex = filterableCategoriesModel.categories.indexOf( + filterableCategoriesModel.selectedCategory + ) + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(horizontal = Keyline1), + verticalAlignment = Alignment.CenterVertically, + ) { + itemsIndexed( + items = filterableCategoriesModel.categories, + key = { i, category -> category.id } + ) { index, category -> + ChoiceChipContent( + text = category.name, + selected = index == selectedIndex, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), + onClick = { onCategorySelected(category) }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChoiceChipContent( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + FilterChip( + selected = selected, + onClick = onClick, + leadingIcon = { + if (selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(id = R.string.cd_selected_category), + modifier = Modifier.height(18.dp) + ) + } + }, + label = { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + }, + colors = FilterChipDefaults.filterChipColors().copy( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + shape = MaterialTheme.shapes.medium, + border = null, + modifier = modifier, + ) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/library/Library.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/library/Library.kt new file mode 100644 index 0000000000..9d654dbdec --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/library/Library.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.home.library + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.LibraryInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.ui.shared.EpisodeListItem +import com.example.jetcaster.util.fullWidthItem + +fun LazyGridScope.libraryItems( + library: LibraryInfo, + navigateToPlayer: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit +) { + fullWidthItem { + Text( + text = stringResource(id = R.string.latest_episodes), + modifier = Modifier.padding( + start = Keyline1, + top = 16.dp, + ), + style = MaterialTheme.typography.headlineLarge, + ) + } + + items( + library, + key = { it.episode.uri } + ) { item -> + EpisodeListItem( + episode = item.episode, + podcast = item.podcast, + onClick = navigateToPlayer, + onQueueEpisode = onQueueEpisode, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt new file mode 100644 index 0000000000..6a82a232cc --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -0,0 +1,893 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.player + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentSize +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.MoreVert +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.Pause +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass +import androidx.window.layout.DisplayFeature +import androidx.window.layout.FoldingFeature +import com.example.jetcaster.R +import com.example.jetcaster.core.player.EpisodePlayerState +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.HtmlTextContainer +import com.example.jetcaster.designsystem.component.ImageBackgroundColorScrim +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.ui.theme.JetcasterTheme +import com.example.jetcaster.ui.tooling.DevicePreviews +import com.example.jetcaster.util.isBookPosture +import com.example.jetcaster.util.isSeparatingPosture +import com.example.jetcaster.util.isTableTopPosture +import com.example.jetcaster.util.verticalGradientScrim +import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy +import com.google.accompanist.adaptive.TwoPane +import com.google.accompanist.adaptive.VerticalTwoPaneStrategy +import java.time.Duration +import kotlinx.coroutines.launch + +/** + * Stateful version of the Podcast player + */ +@Composable +fun PlayerScreen( + windowSizeClass: WindowSizeClass, + displayFeatures: List<DisplayFeature>, + onBackPress: () -> Unit, + viewModel: PlayerViewModel = hiltViewModel(), +) { + val uiState = viewModel.uiState + PlayerScreen( + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onAddToQueue = viewModel::onAddToQueue, + onStop = viewModel::onStop, + playerControlActions = PlayerControlActions( + onPlayPress = viewModel::onPlay, + onPausePress = viewModel::onPause, + onAdvanceBy = viewModel::onAdvanceBy, + onRewindBy = viewModel::onRewindBy, + onSeekingStarted = viewModel::onSeekingStarted, + onSeekingFinished = viewModel::onSeekingFinished, + onNext = viewModel::onNext, + onPrevious = viewModel::onPrevious, + ), + ) +} + +/** + * Stateless version of the Player screen + */ +@Composable +private fun PlayerScreen( + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List<DisplayFeature>, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + onStop: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier +) { + DisposableEffect(Unit) { + onDispose { + onStop() + } + } + + val coroutineScope = rememberCoroutineScope() + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + val snackbarHostState = remember { SnackbarHostState() } + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + modifier = modifier + ) { contentPadding -> + if (uiState.episodePlayerState.currentEpisode != null) { + PlayerContentWithBackground( + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onAddToQueue = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + onAddToQueue() + }, + playerControlActions = playerControlActions, + contentPadding = contentPadding, + ) + } else { + FullScreenLoading() + } + } +} + +@Composable +private fun PlayerBackground( + episode: PlayerEpisode?, + modifier: Modifier, +) { + ImageBackgroundColorScrim( + url = episode?.podcastImageUrl, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + modifier = modifier, + ) +} + +@Composable +fun PlayerContentWithBackground( + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List<DisplayFeature>, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp) +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + PlayerBackground( + episode = uiState.episodePlayerState.currentEpisode, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) + PlayerContent( + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + playerControlActions = playerControlActions, + ) + } +} + +/** + * Wrapper around all actions for the player controls. + */ +data class PlayerControlActions( + val onPlayPress: () -> Unit, + val onPausePress: () -> Unit, + val onAdvanceBy: (Duration) -> Unit, + val onRewindBy: (Duration) -> Unit, + val onNext: () -> Unit, + val onPrevious: () -> Unit, + val onSeekingStarted: () -> Unit, + val onSeekingFinished: (newElapsed: Duration) -> Unit, +) + +@Composable +fun PlayerContent( + uiState: PlayerUiState, + windowSizeClass: WindowSizeClass, + displayFeatures: List<DisplayFeature>, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier +) { + val foldingFeature = displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull() + + // Use a two pane layout if there is a fold impacting layout (meaning it is separating + // or non-flat) or if we have a large enough width to show both. + if ( + windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED || + isBookPosture(foldingFeature) || + isTableTopPosture(foldingFeature) || + isSeparatingPosture(foldingFeature) + ) { + // Determine if we are going to be using a vertical strategy (as if laying out + // both sides in a column). We want to do so if we are in a tabletop posture, + // or we have an impactful horizontal fold. Otherwise, we'll use a horizontal strategy. + val usingVerticalStrategy = + isTableTopPosture(foldingFeature) || + ( + isSeparatingPosture(foldingFeature) && + foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL + ) + + if (usingVerticalStrategy) { + TwoPane( + first = { + PlayerContentTableTopTop( + uiState = uiState, + ) + }, + second = { + PlayerContentTableTopBottom( + uiState = uiState, + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + playerControlActions = playerControlActions, + ) + }, + strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures, + modifier = modifier, + ) + } else { + Column( + modifier = modifier + .fillMaxSize() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f + ) + .systemBarsPadding() + .padding(horizontal = 8.dp) + ) { + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) + TwoPane( + first = { + PlayerContentBookStart(uiState = uiState) + }, + second = { + PlayerContentBookEnd( + uiState = uiState, + playerControlActions = playerControlActions, + ) + }, + strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), + displayFeatures = displayFeatures + ) + } + } + } else { + PlayerContentRegular( + uiState = uiState, + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + playerControlActions = playerControlActions, + modifier = modifier, + ) + } +} + +/** + * The UI for the top pane of a tabletop layout. + */ +@Composable +private fun PlayerContentRegular( + uiState: PlayerUiState, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier +) { + val playerEpisode = uiState.episodePlayerState + val currentEpisode = playerEpisode.currentEpisode ?: return + Column( + modifier = modifier + .fillMaxSize() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f + ) + .systemBarsPadding() + .padding(horizontal = 8.dp) + ) { + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + PlayerImage( + podcastImageUrl = currentEpisode.podcastImageUrl, + modifier = Modifier.weight(10f) + ) + Spacer(modifier = Modifier.height(32.dp)) + PodcastDescription(currentEpisode.title, currentEpisode.podcastName) + Spacer(modifier = Modifier.height(32.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(10f) + ) { + PlayerSlider( + timeElapsed = playerEpisode.timeElapsed, + episodeDuration = currentEpisode.duration, + onSeekingStarted = playerControlActions.onSeekingStarted, + onSeekingFinished = playerControlActions.onSeekingFinished + ) + PlayerButtons( + hasNext = playerEpisode.queue.isNotEmpty(), + isPlaying = playerEpisode.isPlaying, + onPlayPress = playerControlActions.onPlayPress, + onPausePress = playerControlActions.onPausePress, + onAdvanceBy = playerControlActions.onAdvanceBy, + onRewindBy = playerControlActions.onRewindBy, + onNext = playerControlActions.onNext, + onPrevious = playerControlActions.onPrevious, + Modifier.padding(vertical = 8.dp) + ) + } + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +/** + * The UI for the top pane of a tabletop layout. + */ +@Composable +private fun PlayerContentTableTopTop( + uiState: PlayerUiState, + modifier: Modifier = Modifier +) { + // Content for the top part of the screen + val episode = uiState.episodePlayerState.currentEpisode ?: return + Column( + modifier = modifier + .fillMaxWidth() + .verticalGradientScrim( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.50f), + startYPercentage = 1f, + endYPercentage = 0f + ) + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Top + ) + ) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PlayerImage(episode.podcastImageUrl) + } +} + +/** + * The UI for the bottom pane of a tabletop layout. + */ +@Composable +private fun PlayerContentTableTopBottom( + uiState: PlayerUiState, + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier +) { + val episodePlayerState = uiState.episodePlayerState + val episode = uiState.episodePlayerState.currentEpisode ?: return + // Content for the table part of the screen + Column( + modifier = modifier + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + .padding(horizontal = 32.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) + PodcastDescription( + title = episode.title, + podcastName = episode.podcastName, + titleTextStyle = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.weight(0.5f)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(10f) + ) { + PlayerButtons( + hasNext = episodePlayerState.queue.isNotEmpty(), + isPlaying = episodePlayerState.isPlaying, + onPlayPress = playerControlActions.onPlayPress, + onPausePress = playerControlActions.onPausePress, + playerButtonSize = 92.dp, + onAdvanceBy = playerControlActions.onAdvanceBy, + onRewindBy = playerControlActions.onRewindBy, + onNext = playerControlActions.onNext, + onPrevious = playerControlActions.onPrevious, + modifier = Modifier.padding(top = 8.dp) + ) + PlayerSlider( + timeElapsed = episodePlayerState.timeElapsed, + episodeDuration = episode.duration, + onSeekingStarted = playerControlActions.onSeekingStarted, + onSeekingFinished = playerControlActions.onSeekingFinished + ) + } + } +} + +/** + * The UI for the start pane of a book layout. + */ +@Composable +private fun PlayerContentBookStart( + uiState: PlayerUiState, + modifier: Modifier = Modifier +) { + val episode = uiState.episodePlayerState.currentEpisode ?: return + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + vertical = 40.dp, + horizontal = 16.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PodcastInformation( + title = episode.title, + name = episode.podcastName, + summary = episode.summary, + ) + } +} + +/** + * The UI for the end pane of a book layout. + */ +@Composable +private fun PlayerContentBookEnd( + uiState: PlayerUiState, + playerControlActions: PlayerControlActions, + modifier: Modifier = Modifier +) { + val episodePlayerState = uiState.episodePlayerState + val episode = episodePlayerState.currentEpisode ?: return + Column( + modifier = modifier + .fillMaxSize() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + ) { + PlayerImage( + podcastImageUrl = episode.podcastImageUrl, + modifier = Modifier + .padding(vertical = 16.dp) + .weight(1f) + ) + PlayerSlider( + timeElapsed = episodePlayerState.timeElapsed, + episodeDuration = episode.duration, + onSeekingStarted = playerControlActions.onSeekingStarted, + onSeekingFinished = playerControlActions.onSeekingFinished, + ) + PlayerButtons( + hasNext = episodePlayerState.queue.isNotEmpty(), + isPlaying = episodePlayerState.isPlaying, + onPlayPress = playerControlActions.onPlayPress, + onPausePress = playerControlActions.onPausePress, + onAdvanceBy = playerControlActions.onAdvanceBy, + onRewindBy = playerControlActions.onRewindBy, + onNext = playerControlActions.onNext, + onPrevious = playerControlActions.onPrevious, + Modifier.padding(vertical = 8.dp) + ) + } +} + +@Composable +private fun TopAppBar( + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, +) { + Row(Modifier.fillMaxWidth()) { + IconButton(onClick = onBackPress) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = onAddToQueue) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add) + ) + } + IconButton(onClick = { /* TODO */ }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more) + ) + } + } +} + +@Composable +private fun PlayerImage( + podcastImageUrl: String, + modifier: Modifier = Modifier +) { + PodcastImage( + podcastImageUrl = podcastImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .sizeIn(maxWidth = 500.dp, maxHeight = 500.dp) + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium) + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PodcastDescription( + title: String, + podcastName: String, + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall +) { + Text( + text = title, + style = titleTextStyle, + maxLines = 1, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.basicMarquee() + ) + Text( + text = podcastName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) +} + +@Composable +private fun PodcastInformation( + title: String, + name: String, + summary: String, + modifier: Modifier = Modifier, + titleTextStyle: TextStyle = MaterialTheme.typography.headlineSmall, + nameTextStyle: TextStyle = MaterialTheme.typography.displaySmall, +) { + Column( + modifier = modifier.padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = name, + style = nameTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = title, + style = titleTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + HtmlTextContainer(text = summary) { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current + ) + } + } +} + +fun Duration.formatString(): String { + val minutes = this.toMinutes().toString().padStart(2, '0') + val secondsLeft = (this.toSeconds() % 60).toString().padStart(2, '0') + return "$minutes:$secondsLeft" +} + +@Composable +private fun PlayerSlider( + timeElapsed: Duration, + episodeDuration: Duration?, + onSeekingStarted: () -> Unit, + onSeekingFinished: (newElapsed: Duration) -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + var sliderValue by remember(timeElapsed) { mutableStateOf(timeElapsed) } + val maxRange = (episodeDuration?.toSeconds() ?: 0).toFloat() + + Row(Modifier.fillMaxWidth()) { + Text( + text = "${sliderValue.formatString()} • ${episodeDuration?.formatString()}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Slider( + value = sliderValue.seconds.toFloat(), + valueRange = 0f..maxRange, + onValueChange = { + onSeekingStarted() + sliderValue = Duration.ofSeconds(it.toLong()) + }, + onValueChangeFinished = { onSeekingFinished(sliderValue) } + ) + } +} + +@Composable +private fun PlayerButtons( + hasNext: Boolean, + isPlaying: Boolean, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onAdvanceBy: (Duration) -> Unit, + onRewindBy: (Duration) -> Unit, + onNext: () -> Unit, + onPrevious: () -> Unit, + modifier: Modifier = Modifier, + playerButtonSize: Dp = 72.dp, + sideButtonSize: Dp = 48.dp, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + val sideButtonsModifier = Modifier + .size(sideButtonSize) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = CircleShape + ) + .semantics { role = Role.Button } + + val primaryButtonModifier = Modifier + .size(playerButtonSize) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape + ) + .semantics { role = Role.Button } + + Image( + imageVector = Icons.Filled.SkipPrevious, + contentDescription = stringResource(R.string.cd_skip_previous), + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + modifier = sideButtonsModifier + .clickable(enabled = isPlaying, onClick = onPrevious) + .alpha(if (isPlaying) 1f else 0.25f) + ) + Image( + imageVector = Icons.Filled.Replay10, + contentDescription = stringResource(R.string.cd_replay10), + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + modifier = sideButtonsModifier + .clickable { + onRewindBy(Duration.ofSeconds(10)) + } + ) + if (isPlaying) { + Image( + imageVector = Icons.Outlined.Pause, + contentDescription = stringResource(R.string.cd_pause), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer), + modifier = primaryButtonModifier + .padding(8.dp) + .clickable { + onPausePress() + } + ) + } else { + Image( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer), + modifier = primaryButtonModifier + .padding(8.dp) + .clickable { + onPlayPress() + } + ) + } + Image( + imageVector = Icons.Filled.Forward10, + contentDescription = stringResource(R.string.cd_forward10), + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + modifier = sideButtonsModifier + .clickable { + onAdvanceBy(Duration.ofSeconds(10)) + } + ) + Image( + imageVector = Icons.Filled.SkipNext, + contentDescription = stringResource(R.string.cd_skip_next), + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + modifier = sideButtonsModifier + .clickable(enabled = hasNext, onClick = onNext) + .alpha(if (hasNext) 1f else 0.25f) + ) + } +} + +/** + * Full screen circular progress indicator + */ +@Composable +private fun FullScreenLoading(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) { + CircularProgressIndicator() + } +} + +@Preview +@Composable +fun TopAppBarPreview() { + JetcasterTheme { + TopAppBar( + onBackPress = {}, + onAddToQueue = {}, + ) + } +} + +@Preview +@Composable +fun PlayerButtonsPreview() { + JetcasterTheme { + PlayerButtons( + hasNext = false, + isPlaying = true, + onPlayPress = {}, + onPausePress = {}, + onAdvanceBy = {}, + onRewindBy = {}, + onNext = {}, + onPrevious = {}, + ) + } +} + +@DevicePreviews +@Composable +fun PlayerScreenPreview() { + JetcasterTheme { + BoxWithConstraints { + PlayerScreen( + PlayerUiState( + episodePlayerState = EpisodePlayerState( + currentEpisode = PlayerEpisode( + title = "Title", + duration = Duration.ofHours(2), + podcastName = "Podcast", + ), + isPlaying = false, + queue = listOf( + PlayerEpisode(), + PlayerEpisode(), + PlayerEpisode(), + ) + ), + ), + displayFeatures = emptyList(), + windowSizeClass = WindowSizeClass.compute(maxWidth.value, maxHeight.value), + onBackPress = { }, + onAddToQueue = {}, + onStop = {}, + playerControlActions = PlayerControlActions( + onPlayPress = {}, + onPausePress = {}, + onAdvanceBy = {}, + onRewindBy = {}, + onSeekingStarted = {}, + onSeekingFinished = {}, + onNext = {}, + onPrevious = {}, + ) + ) + } + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt new file mode 100644 index 0000000000..a264db77cb --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.player + +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.EpisodePlayerState +import com.example.jetcaster.core.player.model.toPlayerEpisode +import com.example.jetcaster.ui.Screen +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Duration +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +data class PlayerUiState( + val episodePlayerState: EpisodePlayerState = EpisodePlayerState() +) + +/** + * ViewModel that handles the business logic and screen state of the Player screen + */ +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class PlayerViewModel @Inject constructor( + episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + // episodeUri should always be present in the PlayerViewModel. + // If that's not the case, fail crashing the app! + private val episodeUri: String = + Uri.decode(savedStateHandle.get<String>(Screen.ARG_EPISODE_URI)!!) + + var uiState by mutableStateOf(PlayerUiState()) + private set + + init { + viewModelScope.launch { + episodeStore.episodeAndPodcastWithUri(episodeUri).flatMapConcat { + episodePlayer.currentEpisode = it.toPlayerEpisode() + episodePlayer.playerState + }.map { + PlayerUiState(episodePlayerState = it) + }.collect { + uiState = it + } + } + } + + fun onPlay() { + episodePlayer.play() + } + + fun onPause() { + episodePlayer.pause() + } + + fun onStop() { + episodePlayer.stop() + } + + fun onPrevious() { + episodePlayer.previous() + } + + fun onNext() { + episodePlayer.next() + } + + fun onAdvanceBy(duration: Duration) { + episodePlayer.advanceBy(duration) + } + + fun onRewindBy(duration: Duration) { + episodePlayer.rewindBy(duration) + } + + fun onSeekingStarted() { + episodePlayer.onSeekingStarted() + } + + fun onSeekingFinished(duration: Duration) { + episodePlayer.onSeekingFinished(duration) + } + + fun onAddToQueue() { + uiState.episodePlayerState.currentEpisode?.let { + episodePlayer.addToQueue(it) + } + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt new file mode 100644 index 0000000000..4d96997193 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -0,0 +1,383 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.podcast + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.EaseOutExpo +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetcaster.R +import com.example.jetcaster.core.domain.testing.PreviewEpisodes +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.designsystem.theme.Keyline1 +import com.example.jetcaster.ui.shared.EpisodeListItem +import com.example.jetcaster.ui.shared.Loading +import com.example.jetcaster.ui.tooling.DevicePreviews +import com.example.jetcaster.util.fullWidthItem +import kotlinx.coroutines.launch + +@Composable +fun PodcastDetailsScreen( + viewModel: PodcastDetailsViewModel, + navigateToPlayer: (EpisodeInfo) -> Unit, + navigateBack: () -> Unit, + showBackButton: Boolean, + modifier: Modifier = Modifier +) { + val state by viewModel.state.collectAsStateWithLifecycle() + when (val s = state) { + is PodcastUiState.Loading -> { + PodcastDetailsLoadingScreen( + modifier = Modifier.fillMaxSize() + ) + } + is PodcastUiState.Ready -> { + PodcastDetailsScreen( + podcast = s.podcast, + episodes = s.episodes, + toggleSubscribe = viewModel::toggleSusbcribe, + onQueueEpisode = viewModel::onQueueEpisode, + navigateToPlayer = navigateToPlayer, + navigateBack = navigateBack, + showBackButton = showBackButton, + modifier = modifier, + ) + } + } +} + +@Composable +private fun PodcastDetailsLoadingScreen( + modifier: Modifier = Modifier +) { + Loading(modifier = modifier) +} + +@Composable +fun PodcastDetailsScreen( + podcast: PodcastInfo, + episodes: List<EpisodeInfo>, + toggleSubscribe: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + navigateBack: () -> Unit, + showBackButton: Boolean, + modifier: Modifier = Modifier +) { + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + if (showBackButton) { + PodcastDetailsTopAppBar( + navigateBack = navigateBack, + modifier = Modifier.fillMaxWidth() + ) + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + } + ) { contentPadding -> + PodcastDetailsContent( + podcast = podcast, + episodes = episodes, + toggleSubscribe = toggleSubscribe, + onQueueEpisode = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + onQueueEpisode(it) + }, + navigateToPlayer = navigateToPlayer, + modifier = Modifier.padding(contentPadding) + ) + } +} + +@Composable +fun PodcastDetailsContent( + podcast: PodcastInfo, + episodes: List<EpisodeInfo>, + toggleSubscribe: (PodcastInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + navigateToPlayer: (EpisodeInfo) -> Unit, + modifier: Modifier = Modifier +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(362.dp), + modifier.fillMaxSize() + ) { + fullWidthItem { + PodcastDetailsHeaderItem( + podcast = podcast, + toggleSubscribe = toggleSubscribe, + modifier = Modifier.fillMaxWidth() + ) + } + items(episodes, key = { it.uri }) { episode -> + EpisodeListItem( + episode = episode, + podcast = podcast, + onClick = navigateToPlayer, + onQueueEpisode = onQueueEpisode, + modifier = Modifier.fillMaxWidth(), + showPodcastImage = false, + showSummary = true + ) + } + } +} + +@Composable +fun PodcastDetailsHeaderItem( + podcast: PodcastInfo, + toggleSubscribe: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier +) { + BoxWithConstraints( + modifier = modifier.padding(Keyline1) + ) { + val maxImageSize = this.maxWidth / 2 + val imageSize = min(maxImageSize, 148.dp) + Column { + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth() + ) { + PodcastImage( + modifier = Modifier + .size(imageSize) + .clip(MaterialTheme.shapes.large), + podcastImageUrl = podcast.imageUrl, + contentDescription = podcast.title + ) + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = podcast.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineMedium + ) + PodcastDetailsHeaderItemButtons( + isSubscribed = podcast.isSubscribed ?: false, + onClick = { + toggleSubscribe(podcast) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + PodcastDetailsDescription( + podcast = podcast, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) + } + } +} + +@Composable +fun PodcastDetailsDescription( + podcast: PodcastInfo, + modifier: Modifier +) { + var isExpanded by remember { mutableStateOf(false) } + var showSeeMore by remember { mutableStateOf(false) } + Box( + modifier = modifier.clickable { isExpanded = !isExpanded } + ) { + Text( + text = podcast.description, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (isExpanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + onTextLayout = { result -> + showSeeMore = result.hasVisualOverflow + }, + modifier = Modifier.animateContentSize( + animationSpec = tween( + durationMillis = 200, + easing = EaseOutExpo + ) + ) + ) + if (showSeeMore) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .background(MaterialTheme.colorScheme.surface) + ) { + // TODO: Add gradient effect + Text( + text = stringResource(id = R.string.see_more), + style = MaterialTheme.typography.bodyMedium.copy( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold + ), + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } +} + +@Composable +fun PodcastDetailsHeaderItemButtons( + isSubscribed: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row(modifier.padding(top = 16.dp)) { + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = if (isSubscribed) + MaterialTheme.colorScheme.tertiary + else + MaterialTheme.colorScheme.secondary + ), + modifier = Modifier.semantics(mergeDescendants = true) { } + ) { + Icon( + imageVector = if (isSubscribed) + Icons.Default.Check + else + Icons.Default.Add, + contentDescription = null + ) + Text( + text = if (isSubscribed) + stringResource(id = R.string.subscribed) + else + stringResource(id = R.string.subscribe), + modifier = Modifier.padding(start = 8.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + onClick = { /* TODO */ }, + modifier = Modifier.padding(start = 8.dp) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PodcastDetailsTopAppBar( + navigateBack: () -> Unit, + modifier: Modifier = Modifier +) { + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.cd_back) + ) + } + }, + modifier = modifier + ) +} + +@Preview +@Composable +fun PodcastDetailsHeaderItemPreview() { + PodcastDetailsHeaderItem( + podcast = PreviewPodcasts[0], + toggleSubscribe = { }, + ) +} + +@DevicePreviews +@Composable +fun PodcastDetailsScreenPreview() { + PodcastDetailsScreen( + podcast = PreviewPodcasts[0], + episodes = PreviewEpisodes, + toggleSubscribe = { }, + onQueueEpisode = { }, + navigateToPlayer = { }, + navigateBack = { }, + showBackButton = true, + ) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt new file mode 100644 index 0000000000..ac3c6a4267 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.podcast + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.model.asExternalModel +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +sealed interface PodcastUiState { + data object Loading : PodcastUiState + data class Ready( + val podcast: PodcastInfo, + val episodes: List<EpisodeInfo>, + ) : PodcastUiState +} + +/** + * ViewModel that handles the business logic and screen state of the Podcast details screen. + */ +@HiltViewModel(assistedFactory = PodcastDetailsViewModel.Factory::class) +class PodcastDetailsViewModel @AssistedInject constructor( + private val episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, + private val podcastStore: PodcastStore, + @Assisted private val podcastUri: String, +) : ViewModel() { + + private val decodedPodcastUri = Uri.decode(podcastUri) + + val state: StateFlow<PodcastUiState> = + combine( + podcastStore.podcastWithExtraInfo(decodedPodcastUri), + episodeStore.episodesInPodcast(decodedPodcastUri) + ) { podcast, episodeToPodcasts -> + val episodes = episodeToPodcasts.map { it.episode.asExternalModel() } + PodcastUiState.Ready( + podcast = podcast.podcast.asExternalModel().copy(isSubscribed = podcast.isFollowed), + episodes = episodes, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PodcastUiState.Loading + ) + + fun toggleSusbcribe(podcast: PodcastInfo) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + + fun onQueueEpisode(playerEpisode: PlayerEpisode) { + episodePlayer.addToQueue(playerEpisode) + } + + @AssistedFactory + interface Factory { + fun create(podcastUri: String): PodcastDetailsViewModel + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt new file mode 100644 index 0000000000..215a9b24c9 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -0,0 +1,273 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.shared + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +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.MoreVert +import androidx.compose.material.icons.rounded.PlayCircleFilled +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R +import com.example.jetcaster.core.domain.testing.PreviewEpisodes +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.EpisodeInfo +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.designsystem.component.HtmlTextContainer +import com.example.jetcaster.designsystem.component.PodcastImage +import com.example.jetcaster.ui.theme.JetcasterTheme +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun EpisodeListItem( + episode: EpisodeInfo, + podcast: PodcastInfo, + onClick: (EpisodeInfo) -> Unit, + onQueueEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, + showPodcastImage: Boolean = true, + showSummary: Boolean = false, +) { + Box(modifier = modifier.padding(vertical = 8.dp, horizontal = 16.dp)) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + onClick = { onClick(episode) } + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + // Top Part + EpisodeListItemHeader( + episode = episode, + podcast = podcast, + showPodcastImage = showPodcastImage, + showSummary = showSummary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Bottom Part + EpisodeListItemFooter( + episode = episode, + podcast = podcast, + onQueueEpisode = onQueueEpisode, + ) + } + } + } +} + +@Composable +private fun EpisodeListItemFooter( + episode: EpisodeInfo, + podcast: PodcastInfo, + onQueueEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false, radius = 24.dp) + ) { /* TODO */ } + .size(48.dp) + .padding(6.dp) + .semantics { role = Role.Button } + ) + + val duration = episode.duration + Text( + text = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(episode.published), + duration.toMinutes().toInt() + ) + } + // Otherwise we just use the date + else -> MediumDateFormatter.format(episode.published) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + IconButton( + onClick = { + onQueueEpisode( + PlayerEpisode( + podcastInfo = podcast, + episodeInfo = episode + ) + ) + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.cd_add), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton( + onClick = { /* TODO */ }, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.cd_more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun EpisodeListItemHeader( + episode: EpisodeInfo, + podcast: PodcastInfo, + showPodcastImage: Boolean, + showSummary: Boolean, + modifier: Modifier = Modifier +) { + Row(modifier = modifier) { + Column( + modifier = + Modifier + .weight(1f) + .padding(end = 16.dp) + ) { + Text( + text = episode.title, + maxLines = 2, + minLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 2.dp) + ) + + if (showSummary) { + HtmlTextContainer(text = episode.summary) { + Text( + text = it, + maxLines = 2, + minLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + ) + } + } else { + Text( + text = podcast.title, + maxLines = 2, + minLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleSmall, + ) + } + } + if (showPodcastImage) { + EpisodeListItemImage( + podcast = podcast, + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.medium) + ) + } + } +} + +@Composable +private fun EpisodeListItemImage( + podcast: PodcastInfo, + modifier: Modifier = Modifier +) { + PodcastImage( + podcastImageUrl = podcast.imageUrl, + contentDescription = null, + modifier = modifier, + ) +} + +@Preview( + name = "Light Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_NO +) +@Preview( + name = "Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun EpisodeListItemPreview() { + JetcasterTheme { + EpisodeListItem( + episode = PreviewEpisodes[0], + podcast = PreviewPodcasts[0], + onClick = {}, + onQueueEpisode = {}, + showSummary = true + ) + } +} + +private val MediumDateFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/Loading.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/Loading.kt new file mode 100644 index 0000000000..4b96dc6e8a --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/Loading.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.shared + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun Loading(modifier: Modifier = Modifier) { + Surface(modifier = modifier) { + Box( + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator( + Modifier.align(Alignment.Center) + ) + } + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Color.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Color.kt new file mode 100644 index 0000000000..5193851599 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Color.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.theme + +/** + * This is the minimum amount of calculated contrast for a color to be used on top of the + * surface color. These values are defined within the WCAG AA guidelines, and we use a value of + * 3:1 which is the minimum for user-interface components. + */ +const val MinContrastOfPrimaryVsSurface = 3f diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Theme.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Theme.kt new file mode 100644 index 0000000000..bff7e5e4b4 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/theme/Theme.kt @@ -0,0 +1,506 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.theme + +import android.os.Build +import androidx.compose.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.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.example.jetcaster.designsystem.theme.JetcasterShapes +import com.example.jetcaster.designsystem.theme.JetcasterTypography +import com.example.jetcaster.designsystem.theme.backgroundDark +import com.example.jetcaster.designsystem.theme.backgroundDarkHighContrast +import com.example.jetcaster.designsystem.theme.backgroundDarkMediumContrast +import com.example.jetcaster.designsystem.theme.backgroundLight +import com.example.jetcaster.designsystem.theme.backgroundLightHighContrast +import com.example.jetcaster.designsystem.theme.backgroundLightMediumContrast +import com.example.jetcaster.designsystem.theme.errorContainerDark +import com.example.jetcaster.designsystem.theme.errorContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.errorContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.errorContainerLight +import com.example.jetcaster.designsystem.theme.errorContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.errorContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.errorDark +import com.example.jetcaster.designsystem.theme.errorDarkHighContrast +import com.example.jetcaster.designsystem.theme.errorDarkMediumContrast +import com.example.jetcaster.designsystem.theme.errorLight +import com.example.jetcaster.designsystem.theme.errorLightHighContrast +import com.example.jetcaster.designsystem.theme.errorLightMediumContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLight +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.inverseOnSurfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryDark +import com.example.jetcaster.designsystem.theme.inversePrimaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryLight +import com.example.jetcaster.designsystem.theme.inversePrimaryLightHighContrast +import com.example.jetcaster.designsystem.theme.inversePrimaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceDark +import com.example.jetcaster.designsystem.theme.inverseSurfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceLight +import com.example.jetcaster.designsystem.theme.inverseSurfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.inverseSurfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.onBackgroundDark +import com.example.jetcaster.designsystem.theme.onBackgroundDarkHighContrast +import com.example.jetcaster.designsystem.theme.onBackgroundDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onBackgroundLight +import com.example.jetcaster.designsystem.theme.onBackgroundLightHighContrast +import com.example.jetcaster.designsystem.theme.onBackgroundLightMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerDark +import com.example.jetcaster.designsystem.theme.onErrorContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerLight +import com.example.jetcaster.designsystem.theme.onErrorContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onErrorContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorDark +import com.example.jetcaster.designsystem.theme.onErrorDarkHighContrast +import com.example.jetcaster.designsystem.theme.onErrorDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onErrorLight +import com.example.jetcaster.designsystem.theme.onErrorLightHighContrast +import com.example.jetcaster.designsystem.theme.onErrorLightMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDark +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLight +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryDark +import com.example.jetcaster.designsystem.theme.onPrimaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onPrimaryLight +import com.example.jetcaster.designsystem.theme.onPrimaryLightHighContrast +import com.example.jetcaster.designsystem.theme.onPrimaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDark +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLight +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryDark +import com.example.jetcaster.designsystem.theme.onSecondaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSecondaryLight +import com.example.jetcaster.designsystem.theme.onSecondaryLightHighContrast +import com.example.jetcaster.designsystem.theme.onSecondaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceDark +import com.example.jetcaster.designsystem.theme.onSurfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceLight +import com.example.jetcaster.designsystem.theme.onSurfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDark +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDarkHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLight +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLightHighContrast +import com.example.jetcaster.designsystem.theme.onSurfaceVariantLightMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDark +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLight +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryDark +import com.example.jetcaster.designsystem.theme.onTertiaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.onTertiaryLight +import com.example.jetcaster.designsystem.theme.onTertiaryLightHighContrast +import com.example.jetcaster.designsystem.theme.onTertiaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.outlineDark +import com.example.jetcaster.designsystem.theme.outlineDarkHighContrast +import com.example.jetcaster.designsystem.theme.outlineDarkMediumContrast +import com.example.jetcaster.designsystem.theme.outlineLight +import com.example.jetcaster.designsystem.theme.outlineLightHighContrast +import com.example.jetcaster.designsystem.theme.outlineLightMediumContrast +import com.example.jetcaster.designsystem.theme.outlineVariantDark +import com.example.jetcaster.designsystem.theme.outlineVariantDarkHighContrast +import com.example.jetcaster.designsystem.theme.outlineVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.outlineVariantLight +import com.example.jetcaster.designsystem.theme.outlineVariantLightHighContrast +import com.example.jetcaster.designsystem.theme.outlineVariantLightMediumContrast +import com.example.jetcaster.designsystem.theme.primaryContainerDark +import com.example.jetcaster.designsystem.theme.primaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.primaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.primaryContainerLight +import com.example.jetcaster.designsystem.theme.primaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.primaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.primaryDark +import com.example.jetcaster.designsystem.theme.primaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.primaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.primaryLight +import com.example.jetcaster.designsystem.theme.primaryLightHighContrast +import com.example.jetcaster.designsystem.theme.primaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.scrimDark +import com.example.jetcaster.designsystem.theme.scrimDarkHighContrast +import com.example.jetcaster.designsystem.theme.scrimDarkMediumContrast +import com.example.jetcaster.designsystem.theme.scrimLight +import com.example.jetcaster.designsystem.theme.scrimLightHighContrast +import com.example.jetcaster.designsystem.theme.scrimLightMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerDark +import com.example.jetcaster.designsystem.theme.secondaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerLight +import com.example.jetcaster.designsystem.theme.secondaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.secondaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryDark +import com.example.jetcaster.designsystem.theme.secondaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.secondaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryLight +import com.example.jetcaster.designsystem.theme.secondaryLightHighContrast +import com.example.jetcaster.designsystem.theme.secondaryLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightDark +import com.example.jetcaster.designsystem.theme.surfaceBrightDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightLight +import com.example.jetcaster.designsystem.theme.surfaceBrightLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceBrightLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerDark +import com.example.jetcaster.designsystem.theme.surfaceContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLight +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLight +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerHighestLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDark +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLight +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceContainerLowestLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceDark +import com.example.jetcaster.designsystem.theme.surfaceDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceDimDark +import com.example.jetcaster.designsystem.theme.surfaceDimDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceDimDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceDimLight +import com.example.jetcaster.designsystem.theme.surfaceDimLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceDimLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceLight +import com.example.jetcaster.designsystem.theme.surfaceLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceLightMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantDark +import com.example.jetcaster.designsystem.theme.surfaceVariantDarkHighContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantDarkMediumContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantLight +import com.example.jetcaster.designsystem.theme.surfaceVariantLightHighContrast +import com.example.jetcaster.designsystem.theme.surfaceVariantLightMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerDark +import com.example.jetcaster.designsystem.theme.tertiaryContainerDarkHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerLight +import com.example.jetcaster.designsystem.theme.tertiaryContainerLightHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryContainerLightMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryDark +import com.example.jetcaster.designsystem.theme.tertiaryDarkHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryDarkMediumContrast +import com.example.jetcaster.designsystem.theme.tertiaryLight +import com.example.jetcaster.designsystem.theme.tertiaryLightHighContrast +import com.example.jetcaster.designsystem.theme.tertiaryLightMediumContrast + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +@Immutable +data class ColorFamily( + val color: Color, + val onColor: Color, + val colorContainer: Color, + val onColorContainer: Color +) + +val unspecified_scheme = ColorFamily( + Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified +) + +@Composable +fun JetcasterTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkScheme + else -> lightScheme + } + + MaterialTheme( + colorScheme = colorScheme, + shapes = JetcasterShapes, + typography = JetcasterTypography, + content = content + ) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/tooling/DevicePreviews.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/tooling/DevicePreviews.kt new file mode 100644 index 0000000000..93a5414e09 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/tooling/DevicePreviews.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.tooling + +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview + +@Preview(name = "small-phone", device = Devices.PIXEL_4A) +@Preview(name = "phone", device = Devices.PHONE) +@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") +@Preview(name = "foldable", device = Devices.FOLDABLE) +@Preview(name = "tablet", device = Devices.TABLET) +annotation class DevicePreviews diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Buttons.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Buttons.kt new file mode 100644 index 0000000000..c90ffc9d82 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Buttons.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.example.jetcaster.R + +@Composable +fun ToggleFollowPodcastIconButton( + isFollowed: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val clickLabel = stringResource(if (isFollowed) R.string.cd_unfollow else R.string.cd_follow) + IconButton( + onClick = onClick, + modifier = modifier.semantics { + onClick(label = clickLabel, action = null) + } + ) { + Icon( + // TODO: think about animating these icons + imageVector = when { + isFollowed -> Icons.Default.Check + else -> Icons.Default.Add + }, + contentDescription = when { + isFollowed -> stringResource(R.string.cd_following) + else -> stringResource(R.string.cd_not_following) + }, + tint = animateColorAsState( + when { + isFollowed -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.primary + } + ).value, + modifier = Modifier + .shadow( + elevation = animateDpAsState(if (isFollowed) 0.dp else 1.dp).value, + shape = MaterialTheme.shapes.small + ) + .background( + color = animateColorAsState( + when { + isFollowed -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surfaceContainerHighest + } + ).value, + shape = CircleShape + ) + .padding(4.dp) + ) + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Colors.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Colors.kt new file mode 100644 index 0000000000..96d8df128a --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/Colors.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import kotlin.math.max +import kotlin.math.min + +fun Color.contrastAgainst(background: Color): Float { + val fg = if (alpha < 1f) compositeOver(background) else this + + val fgLuminance = fg.luminance() + 0.05f + val bgLuminance = background.luminance() + 0.05f + + return max(fgLuminance, bgLuminance) / min(fgLuminance, bgLuminance) +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/GradientScrim.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/GradientScrim.kt new file mode 100644 index 0000000000..6713734728 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/GradientScrim.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.annotation.FloatRange +import androidx.compose.foundation.background +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** + * Applies a radial gradient scrim in the foreground emanating from the top + * center quarter of the element. + */ +fun Modifier.radialGradientScrim(color: Color): Modifier { + val radialGradient = object : ShaderBrush() { + override fun createShader(size: Size): Shader { + val largerDimension = max(size.height, size.width) + return RadialGradientShader( + center = size.center.copy(y = size.height / 4), + colors = listOf(color, Color.Transparent), + radius = largerDimension / 2, + colorStops = listOf(0f, 0.9f) + ) + } + } + return this.background(radialGradient) +} + +/** + * Draws a vertical gradient scrim in the foreground. + * + * @param color The color of the gradient scrim. + * @param startYPercentage The start y value, in percentage of the layout's height (0f to 1f) + * @param endYPercentage The end y value, in percentage of the layout's height (0f to 1f). This + * value can be smaller than [startYPercentage]. If that is the case, then the gradient direction + * will reverse (decaying downwards, instead of decaying upwards). + * @param decay The exponential decay to apply to the gradient. Defaults to `1.0f` which is + * a linear gradient. + * @param numStops The number of color stops to draw in the gradient. Higher numbers result in + * the higher visual quality at the cost of draw performance. Defaults to `16`. + */ +fun Modifier.verticalGradientScrim( + color: Color, + @FloatRange(from = 0.0, to = 1.0) startYPercentage: Float = 0f, + @FloatRange(from = 0.0, to = 1.0) endYPercentage: Float = 1f, + decay: Float = 1.0f, + numStops: Int = 16 +) = this then VerticalGradientElement(color, startYPercentage, endYPercentage, decay, numStops) + +private data class VerticalGradientElement( + var color: Color, + var startYPercentage: Float = 0f, + var endYPercentage: Float = 1f, + var decay: Float = 1.0f, + var numStops: Int = 16 +) : ModifierNodeElement<VerticalGradientModifier>() { + fun createOnDraw(): DrawScope.() -> Unit { + val colors = if (decay != 1f) { + // If we have a non-linear decay, we need to create the color gradient steps + // manually + val baseAlpha = color.alpha + List(numStops) { i -> + val x = i * 1f / (numStops - 1) + val opacity = x.pow(decay) + color.copy(alpha = baseAlpha * opacity) + } + } else { + // If we have a linear decay, we just create a simple list of start + end colors + listOf(color.copy(alpha = 0f), color) + } + + val brush = + // Reverse the gradient if decaying downwards + Brush.verticalGradient( + colors = if (startYPercentage < endYPercentage) colors else colors.reversed(), + ) + + return { + val topLeft = Offset(0f, size.height * min(startYPercentage, endYPercentage)) + val bottomRight = + Offset(size.width, size.height * max(startYPercentage, endYPercentage)) + + drawRect( + topLeft = topLeft, + size = Rect(topLeft, bottomRight).size, + brush = brush + ) + } + } + + override fun create() = VerticalGradientModifier(createOnDraw()) + + override fun update(node: VerticalGradientModifier) { + node.onDraw = createOnDraw() + } + + /** + * Allow this custom modifier to be inspected in the layout inspector + **/ + override fun InspectorInfo.inspectableProperties() { + name = "verticalGradientScrim" + properties["color"] = color + properties["startYPercentage"] = startYPercentage + properties["endYPercentage"] = endYPercentage + properties["decay"] = decay + properties["numStops"] = numStops + } +} + +private class VerticalGradientModifier( + var onDraw: DrawScope.() -> Unit +) : Modifier.Node(), DrawModifierNode { + + override fun ContentDrawScope.draw() { + onDraw() + drawContent() + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.kt new file mode 100644 index 0000000000..6233653f67 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/LazyVerticalGrid.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.jetcaster.util + +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.runtime.Composable + +/** + * An item that occupies the entire width. + */ +fun LazyGridScope.fullWidthItem( + key: Any? = null, + contentType: Any? = null, + content: @Composable LazyGridItemScope.() -> Unit +) = item( + span = { GridItemSpan(this.maxLineSpan) }, + key = key, + contentType = contentType, + content = content +) diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/util/PluralResources.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/PluralResources.kt similarity index 92% rename from Jetcaster/app/src/main/java/com/example/jetcaster/util/PluralResources.kt rename to Jetcaster/mobile/src/main/java/com/example/jetcaster/util/PluralResources.kt index 91867a98b8..36c0b4ea4a 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/util/PluralResources.kt +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/PluralResources.kt @@ -18,7 +18,7 @@ package com.example.jetcaster.util import androidx.annotation.PluralsRes import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.AmbientContext +import androidx.compose.ui.platform.LocalContext /** * Load a quantity string resource. @@ -29,7 +29,7 @@ import androidx.compose.ui.platform.AmbientContext */ @Composable fun quantityStringResource(@PluralsRes id: Int, quantity: Int): String { - val context = AmbientContext.current + val context = LocalContext.current return context.resources.getQuantityString(id, quantity) } @@ -43,6 +43,6 @@ fun quantityStringResource(@PluralsRes id: Int, quantity: Int): String { */ @Composable fun quantityStringResource(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any): String { - val context = AmbientContext.current + val context = LocalContext.current return context.resources.getQuantityString(id, quantity, *formatArgs) } diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/ViewModel.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/ViewModel.kt new file mode 100644 index 0000000000..ff27946656 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/ViewModel.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory + +/** + * Returns a [ViewModelProvider.Factory] which will return the result of [create] when it's + * [ViewModelProvider.Factory.create] function is called. + * + * If the created [ViewModel] does not match the requested class, an [IllegalArgumentException] + * exception is thrown. + */ +inline fun <reified VM : ViewModel> viewModelProviderFactoryOf( + crossinline create: () -> VM +): ViewModelProvider.Factory = viewModelFactory { + initializer { + create() + } +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowInfoUtil.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowInfoUtil.kt new file mode 100644 index 0000000000..1d7c2e7a46 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowInfoUtil.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.util + +import androidx.window.layout.FoldingFeature +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +fun isTableTopPosture(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.HALF_OPENED && + foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL +} + +@OptIn(ExperimentalContracts::class) +fun isBookPosture(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.HALF_OPENED && + foldFeature.orientation == FoldingFeature.Orientation.VERTICAL +} + +@OptIn(ExperimentalContracts::class) +fun isSeparatingPosture(foldFeature: FoldingFeature?): Boolean { + contract { returns(true) implies (foldFeature != null) } + return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowSizeClass.kt new file mode 100644 index 0000000000..b4c90b3729 --- /dev/null +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/util/WindowSizeClass.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.util + +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass + +/** + * Returns true if the width or height size classes are compact. + */ +val WindowSizeClass.isCompact: Boolean + get() = windowWidthSizeClass == WindowWidthSizeClass.COMPACT || + windowHeightSizeClass == WindowHeightSizeClass.COMPACT diff --git a/Jetcaster/app/src/main/res/drawable-nodpi/ic_text_logo.xml b/Jetcaster/mobile/src/main/res/drawable-nodpi/ic_text_logo.xml similarity index 100% rename from Jetcaster/app/src/main/res/drawable-nodpi/ic_text_logo.xml rename to Jetcaster/mobile/src/main/res/drawable-nodpi/ic_text_logo.xml diff --git a/Jetcaster/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Jetcaster/mobile/src/main/res/drawable-v26/ic_launcher_foreground.xml similarity index 100% rename from Jetcaster/app/src/main/res/drawable-v26/ic_launcher_foreground.xml rename to Jetcaster/mobile/src/main/res/drawable-v26/ic_launcher_foreground.xml diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_launcher_background.xml b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..7f2643db2d --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,185 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#121212" + android:pathData="M0,0h108v108h-108z" /> + <path + android:fillColor="#00000000" + android:pathData="M9,0L9,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,0L19,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,0L29,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,0L39,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,0L49,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,0L59,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,0L69,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,0L79,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M89,0L89,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M99,0L99,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,9L108,9" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,19L108,19" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,29L108,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,39L108,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,49L108,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,59L108,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,69L108,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,79L108,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,89L108,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,99L108,99" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,29L89,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,39L89,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,49L89,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,59L89,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,69L89,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,79L89,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,19L29,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,19L39,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,19L49,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,19L59,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,19L69,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,19L79,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> +</vector> diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_launcher_foreground.xml b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..c19b699858 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,66 @@ +<!-- + ~ 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <group android:scaleX="0.9182609" + android:scaleY="0.9182609" + android:translateX="4.4139132" + android:translateY="4.4139132"> + <group> + <clip-path + android:pathData="M31.5,76.63l45.12,-0l0,-45.13l-45.12,-0z"/> + <path + android:pathData="M47.62,66.48C45.84,66.48 44.39,65.69 44.39,64.73V33.25C44.39,32.29 45.84,31.5 47.62,31.5C49.39,31.5 50.84,32.29 50.84,33.25V64.73C50.84,65.69 49.39,66.48 47.62,66.48Z" + android:fillColor="#F27405"/> + <path + android:pathData="M34.72,55.99C32.95,55.99 31.5,55.2 31.5,54.24V43.74C31.5,42.78 32.95,41.99 34.72,41.99C36.5,41.99 37.95,42.78 37.95,43.74V54.24C37.95,55.2 36.5,55.99 34.72,55.99Z" + android:fillColor="#F27405"/> + <path + android:pathData="M60.51,59.49C58.74,59.49 57.28,58.7 57.28,57.74V40.25C57.28,39.28 58.74,38.5 60.51,38.5C62.28,38.5 63.73,39.28 63.73,40.25V57.74C63.73,58.7 62.28,59.49 60.51,59.49Z" + android:fillColor="#F27405"/> + <path + android:pathData="M73.4,54.24C71.63,54.24 70.18,53.45 70.18,52.49V45.49C70.18,44.53 71.63,43.74 73.4,43.74C75.17,43.74 76.62,44.53 76.62,45.49V52.49C76.62,53.45 75.17,54.24 73.4,54.24Z" + android:fillColor="#F27405"/> + <path + android:pathData="M47.62,71.18C45.84,71.18 44.39,70.39 44.39,69.43V37.95C44.39,36.99 45.84,36.2 47.62,36.2C49.39,36.2 50.84,36.99 50.84,37.95V69.43C50.84,70.39 49.39,71.18 47.62,71.18Z" + android:fillColor="#FF9F0C"/> + <path + android:pathData="M34.72,60.69C32.95,60.69 31.5,59.9 31.5,58.94V48.44C31.5,47.48 32.95,46.69 34.72,46.69C36.5,46.69 37.95,47.48 37.95,48.44V58.94C37.95,59.9 36.5,60.69 34.72,60.69Z" + android:fillColor="#FF9F0C"/> + <path + android:pathData="M60.51,64.19C58.74,64.19 57.28,63.4 57.28,62.44V44.95C57.28,43.98 58.74,43.2 60.51,43.2C62.28,43.2 63.73,43.98 63.73,44.95V62.44C63.73,63.4 62.28,64.19 60.51,64.19Z" + android:fillColor="#FF9F0C"/> + <path + android:pathData="M73.4,58.94C71.63,58.94 70.18,58.15 70.18,57.19V50.19C70.18,49.23 71.63,48.44 73.4,48.44C75.17,48.44 76.62,49.23 76.62,50.19V57.19C76.62,58.15 75.17,58.94 73.4,58.94Z" + android:fillColor="#FF9F0C"/> + <path + android:pathData="M47.61,76.63C45.84,76.63 44.39,75.92 44.39,75.06V46.95C44.39,46.09 45.84,45.39 47.61,45.39C49.39,45.39 50.84,46.09 50.84,46.95V75.06C50.84,75.92 49.39,76.63 47.61,76.63Z" + android:fillColor="#FFD083"/> + <path + android:pathData="M34.72,67.25C32.95,67.25 31.5,66.55 31.5,65.69V56.32C31.5,55.46 32.95,54.76 34.72,54.76C36.49,54.76 37.95,55.46 37.95,56.32V65.69C37.95,66.55 36.49,67.25 34.72,67.25Z" + android:fillColor="#FFD083"/> + <path + android:pathData="M60.5,70.38C58.73,70.38 57.28,69.67 57.28,68.82V53.2C57.28,52.34 58.73,51.63 60.5,51.63C62.28,51.63 63.73,52.34 63.73,53.2V68.82C63.73,69.67 62.28,70.38 60.5,70.38Z" + android:fillColor="#FFD083"/> + <path + android:pathData="M73.39,65.69C71.62,65.69 70.17,64.99 70.17,64.13V57.88C70.17,57.02 71.62,56.32 73.39,56.32C75.17,56.32 76.62,57.02 76.62,57.88V64.13C76.62,64.99 75.17,65.69 73.39,65.69Z" + android:fillColor="#FFD083"/> + </group> + </group> +</vector> diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_launcher_monochrome.xml b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..e71686aef8 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,61 @@ +<!-- + ~ 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <group> + <clip-path + android:pathData="M26.25,81.75l55.5,-0l0,-55.5l-55.5,-0z"/> + <path + android:pathData="M46.07,69.27C43.89,69.27 42.11,68.31 42.11,67.12V28.4C42.11,27.22 43.89,26.25 46.07,26.25C48.25,26.25 50.03,27.22 50.03,28.4V67.12C50.03,68.31 48.25,69.27 46.07,69.27Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M30.21,56.37C28.03,56.37 26.25,55.4 26.25,54.21V41.31C26.25,40.12 28.03,39.16 30.21,39.16C32.39,39.16 34.18,40.12 34.18,41.31V54.21C34.18,55.4 32.39,56.37 30.21,56.37Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M61.93,60.67C59.75,60.67 57.96,59.7 57.96,58.52V37.01C57.96,35.82 59.75,34.85 61.93,34.85C64.11,34.85 65.89,35.82 65.89,37.01V58.52C65.89,59.7 64.11,60.67 61.93,60.67Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M77.78,54.21C75.6,54.21 73.82,53.25 73.82,52.06V43.46C73.82,42.28 75.6,41.31 77.78,41.31C79.96,41.31 81.75,42.28 81.75,43.46V52.06C81.75,53.25 79.96,54.21 77.78,54.21Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M46.07,75.05C43.89,75.05 42.11,74.09 42.11,72.9V34.18C42.11,33 43.89,32.03 46.07,32.03C48.25,32.03 50.03,33 50.03,34.18V72.9C50.03,74.09 48.25,75.05 46.07,75.05Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M30.21,62.15C28.03,62.15 26.25,61.18 26.25,60V47.09C26.25,45.91 28.03,44.94 30.21,44.94C32.39,44.94 34.18,45.91 34.18,47.09V60C34.18,61.18 32.39,62.15 30.21,62.15Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M61.93,66.45C59.75,66.45 57.96,65.48 57.96,64.3V42.79C57.96,41.6 59.75,40.64 61.93,40.64C64.11,40.64 65.89,41.6 65.89,42.79V64.3C65.89,65.48 64.11,66.45 61.93,66.45Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M77.78,60C75.6,60 73.82,59.03 73.82,57.84V49.24C73.82,48.06 75.6,47.09 77.78,47.09C79.96,47.09 81.75,48.06 81.75,49.24V57.84C81.75,59.03 79.96,60 77.78,60Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M46.07,81.75C43.89,81.75 42.1,80.89 42.1,79.83V45.25C42.1,44.19 43.89,43.33 46.07,43.33C48.25,43.33 50.03,44.19 50.03,45.25V79.83C50.03,80.89 48.25,81.75 46.07,81.75Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M30.21,70.22C28.03,70.22 26.25,69.36 26.25,68.3V56.78C26.25,55.72 28.03,54.85 30.21,54.85C32.39,54.85 34.18,55.72 34.18,56.78V68.3C34.18,69.36 32.39,70.22 30.21,70.22Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M61.92,74.07C59.74,74.07 57.96,73.2 57.96,72.14V52.93C57.96,51.88 59.74,51.01 61.92,51.01C64.1,51.01 65.88,51.88 65.88,52.93V72.14C65.88,73.2 64.1,74.07 61.92,74.07Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M77.78,68.3C75.6,68.3 73.81,67.44 73.81,66.38V58.7C73.81,57.64 75.6,56.78 77.78,56.78C79.96,56.78 81.74,57.64 81.74,58.7V66.38C81.74,67.44 79.96,68.3 77.78,68.3Z" + android:fillColor="#171D1A"/> + </group> +</vector> diff --git a/Jetcaster/mobile/src/main/res/drawable/ic_logo.xml b/Jetcaster/mobile/src/main/res/drawable/ic_logo.xml new file mode 100644 index 0000000000..8d00d29968 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,31 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#F27405" + android:pathData="M8.5,19c-0.83,0 -1.5,-0.43 -1.5,-0.95V0.95C7,0.43 7.67,0 8.5,0c0.82,0 1.5,0.43 1.5,0.95v17.1c0,0.52 -0.68,0.95 -1.5,0.95zM1.5,13c-0.83,0 -1.5,-0.4 -1.5,-0.88V6.88C0,6.39 0.67,6 1.5,6s1.5,0.4 1.5,0.88v5.24c0,0.49 -0.67,0.88 -1.5,0.88zM15.5,15c-0.82,0 -1.5,-0.41 -1.5,-0.92V4.92c0,-0.5 0.68,-0.92 1.5,-0.92 0.83,0 1.5,0.41 1.5,0.92v9.16c0,0.5 -0.67,0.92 -1.5,0.92zM22.5,12c-0.83,0 -1.5,-0.45 -1.5,-1V7c0,-0.55 0.67,-1 1.5,-1 0.82,0 1.5,0.45 1.5,1v4c0,0.55 -0.68,1 -1.5,1z" /> + <path + android:fillColor="#FF9F0C" + android:pathData="M8.5,21c-0.83,0 -1.5,-0.43 -1.5,-0.95V2.95C7,2.43 7.67,2 8.5,2c0.82,0 1.5,0.43 1.5,0.95v17.1c0,0.52 -0.68,0.95 -1.5,0.95zM1.5,15c-0.83,0 -1.5,-0.4 -1.5,-0.88V8.87C0,8.4 0.67,8 1.5,8s1.5,0.4 1.5,0.87v5.25c0,0.49 -0.67,0.88 -1.5,0.88zM15.5,17c-0.82,0 -1.5,-0.41 -1.5,-0.92V6.92c0,-0.5 0.68,-0.92 1.5,-0.92 0.83,0 1.5,0.41 1.5,0.92v9.16c0,0.5 -0.67,0.92 -1.5,0.92zM22.5,15c-0.83,0 -1.5,-0.45 -1.5,-1v-4c0,-0.55 0.67,-1 1.5,-1 0.82,0 1.5,0.45 1.5,1v4c0,0.55 -0.68,1 -1.5,1z" /> + <path + android:fillColor="#FFD083" + android:pathData="M8.5,24c-0.83,0 -1.5,-0.38 -1.5,-0.85V7.85C7,7.38 7.67,7 8.5,7s1.5,0.38 1.5,0.85v15.3c0,0.47 -0.67,0.85 -1.5,0.85zM1.5,19c-0.82,0 -1.5,-0.4 -1.5,-0.87v-5.26C0,12.4 0.68,12 1.5,12c0.83,0 1.5,0.4 1.5,0.87v5.26c0,0.48 -0.67,0.87 -1.5,0.87zM15.5,21c-0.83,0 -1.5,-0.38 -1.5,-0.83v-8.34c0,-0.45 0.67,-0.83 1.5,-0.83 0.82,0 1.5,0.38 1.5,0.83v8.34c0,0.45 -0.68,0.83 -1.5,0.83zM22.5,18c-0.82,0 -1.5,-0.38 -1.5,-0.83v-3.34c0,-0.45 0.68,-0.83 1.5,-0.83 0.83,0 1.5,0.38 1.5,0.83v3.34c0,0.45 -0.67,0.83 -1.5,0.83z" /> +</vector> diff --git a/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..96e4ade2ed --- /dev/null +++ b/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background"/> + <foreground android:drawable="@drawable/ic_launcher_foreground"/> +</adaptive-icon> diff --git a/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..96e4ade2ed --- /dev/null +++ b/Jetcaster/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background"/> + <foreground android:drawable="@drawable/ic_launcher_foreground"/> +</adaptive-icon> diff --git a/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..1e97e1b9ec Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..1e97e1b9ec Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..821e87fac3 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..821e87fac3 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..347493f918 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..347493f918 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..463f54c5d2 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..463f54c5d2 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..50721da443 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..50721da443 Binary files /dev/null and b/Jetcaster/mobile/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Jetcaster/app/src/main/res/values/colors.xml b/Jetcaster/mobile/src/main/res/values/colors.xml similarity index 100% rename from Jetcaster/app/src/main/res/values/colors.xml rename to Jetcaster/mobile/src/main/res/values/colors.xml diff --git a/Jetcaster/mobile/src/main/res/values/strings.xml b/Jetcaster/mobile/src/main/res/values/strings.xml new file mode 100644 index 0000000000..078f542b1f --- /dev/null +++ b/Jetcaster/mobile/src/main/res/values/strings.xml @@ -0,0 +1,67 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<resources> + <string name="app_name">Jetcaster</string> + + <string name="connection_error_title">Connection error</string> + <string name="connection_error_message">Unable to fetch podcasts feeds.\nCheck your internet connection and try again.</string> + <string name="retry_label">Retry</string> + + <string name="your_podcasts">Your podcasts</string> + <string name="latest_episodes">Latest episodes</string> + + <string name="home_library">Your library</string> + <string name="home_discover">Discover</string> + + <string name="updated_longer">Updated a while ago</string> + <plurals name="updated_weeks_ago"> + <item quantity="one">Updated %d week ago</item> + <item quantity="other">Updated %d weeks ago</item> + </plurals> + <plurals name="updated_days_ago"> + <item quantity="one">Updated yesterday</item> + <item quantity="other">Updated %d days ago</item> + </plurals> + <string name="updated_today">Updated today</string> + + <string name="episode_date_duration">%1$s • %2$d mins</string> + + <string name="cd_account">Account</string> + <string name="cd_add">Add</string> + <string name="cd_back">Back</string> + <string name="cd_follow">Follow</string> + <string name="cd_following">Following</string> + <string name="cd_forward10">Forward 10 seconds</string> + <string name="cd_more">More</string> + <string name="cd_not_following">Not following</string> + <string name="cd_pause">Pause</string> + <string name="cd_play">Play</string> + <string name="cd_replay10">Replay 10 seconds</string> + <string name="cd_search">Search</string> + <string name="cd_selected_category">Selected category</string> + <string name="cd_skip_next">Skip next</string> + <string name="cd_skip_previous">Skip previous</string> + <string name="cd_unfollow">Unfollow</string> + <string name="episode_added_to_your_queue">Episode added to your queue</string> + <string name="cd_podcast_image">Podcast image</string> + <string name="subscribe">Subscribe</string> + <string name="subscribed">Subscribed</string> + <string name="see_more">see more</string> + <string name="search_for_a_podcast">Search for a podcast</string> + <string name="an_error_has_occurred">An error has occurred.</string> + +</resources> diff --git a/Jetcaster/mobile/src/main/res/values/themes.xml b/Jetcaster/mobile/src/main/res/values/themes.xml new file mode 100644 index 0000000000..79be4dfdf6 --- /dev/null +++ b/Jetcaster/mobile/src/main/res/values/themes.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<resources> + + <style name="Theme.Jetcaster" parent="android:Theme.Material.NoActionBar"> + <item name="android:colorPrimary">#ff00ff</item> + <item name="android:colorAccent">#ff00ff</item> + </style> + +</resources> diff --git a/Jetcaster/settings.gradle b/Jetcaster/settings.gradle deleted file mode 100644 index 271819e4b2..0000000000 --- a/Jetcaster/settings.gradle +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -include ':app' -rootProject.name = "Jetcaster" \ No newline at end of file diff --git a/Jetcaster/settings.gradle.kts b/Jetcaster/settings.gradle.kts new file mode 100644 index 0000000000..2eee9e75ae --- /dev/null +++ b/Jetcaster/settings.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +val snapshotVersion : String? = System.getenv("COMPOSE_SNAPSHOT_ID") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + snapshotVersion?.let { + println("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") + maven { url = uri("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") } + } + + google() + mavenCentral() + } +} +rootProject.name = "Jetcaster" +include( + ":mobile", + ":core:data", + ":core:data-testing", + ":core:domain", + ":core:domain-testing", + ":core:designsystem", + ":tv", + ":wear", + ":glancewidget" +) +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") diff --git a/Jetcaster/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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. +--> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-feature + android:name="android.hardware.touchscreen" + android:required="false" /> + <uses-feature + android:name="android.software.leanback" + android:required="false" /> + + <uses-permission android:name="android.permission.INTERNET" /> + + <application + android:allowBackup="true" + android:banner="@mipmap/ic_launcher" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/Theme.Jetcaster" + android:name=".JetCasterTvApp"> + <activity + android:name=".MainActivity" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> \ 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<CategoryInfo>) : List<CategoryInfo> by member { + + fun intoCategoryList(): List<Category> { + return map(CategoryInfo::intoCategory) + } + + companion object { + fun from(list: List<Category>): 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<CategorySelection> +) : List<CategorySelection> 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<PlayerEpisode>) : List<PlayerEpisode> 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<PodcastInfo> 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<CategoryInfo?>(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<String?>(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<String>(Screen.Podcast.PARAMETER_NAME) + + @OptIn(ExperimentalCoroutinesApi::class) + private val podcastFlow = + handle.getStateFlow<String?>(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<List<CategoryInfo>>(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<CategoryInfo>) : 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 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="200dp" + android:height="40dp" + android:viewportWidth="510" + android:viewportHeight="104"> + <path + android:pathData="M27.8,91c2.1,-4 3.2,-8.3 3.2,-13.3V24.3c0,-1.6 -0.5,-2.9 -1.5,-4 -1,-1 -2.4,-1.5 -4,-1.5s-3,0.5 -4,1.5c-1,1.1 -1.5,2.4 -1.5,4v53.4c0,4.3 -1.4,7.8 -4.1,10.5 -2.8,2.7 -6.3,4 -10.5,4a5,5 0,0 0,-3.9 1.6C0.5,95 0,96.2 0,97.8c0,1.5 0.4,2.8 1.5,4 1,1 2.3,1.4 4,1.4 4.9,0 9.3,-1 13.2,-3.2a23,23 0,0 0,9 -9z" + android:fillColor="#000"/> + <path + android:pathData="M100.4,47.7c0,1.4 -0.5,2.6 -1.4,3.5 -1,0.8 -2.1,1.3 -3.5,1.3H54.4c0.7,5 3,9.1 6.8,12.2a21,21 0,0 0,14 4.6c2.1,0 4.3,-0.3 6.6,-1.2 2.4,-0.7 4.4,-1.7 5.9,-2.9a5.7,5.7 0,0 1,6.8 -0.1c1.3,1 2,2.4 2,3.7 0,1.3 -0.7,2.4 -1.8,3.2 -2.5,2 -5.5,3.6 -9.2,4.8a32,32 0,0 1,-26.3 -2,27.7 27.7,0 0,1 -11,-10.7 30.4,30.4 0,0 1,-3.9 -15.4c0,-5.8 1.2,-11 3.7,-15.5s6,-8.1 10.4,-10.7c4.4,-2.6 9.5,-3.9 15.1,-3.9 5.5,0 10.3,1.3 14.4,3.7 4,2.5 7.1,6 9.3,10.3 2.1,4.4 3.2,9.5 3.2,15.1zM73.5,27.9a19,19 0,0 0,-12.9 4.3c-3.2,3 -5.2,6.8 -6.1,11.6h36C89.8,39 88,35.2 85,32.2c-3,-2.8 -6.8,-4.3 -11.6,-4.3z" + android:fillColor="#000" + android:fillType="evenOdd"/> + <path + android:pathData="M117,21.6v36c0,4 0.7,7.6 2.5,10.7A17.8,17.8 0,0 0,136 78.6h2c1.8,0 3.3,-0.5 4.5,-1.6 1.2,-1 1.8,-2.2 1.8,-3.8 0,-1.6 -0.5,-2.9 -1.4,-4 -0.8,-1 -2,-1.5 -3.2,-1.5L136,67.7a7,7 0,0 1,-5.8 -2.9c-1.7,-2 -2.4,-4.3 -2.4,-7.2L127.8,30.9h9.2c1.5,0 2.7,-0.4 3.7,-1.3 0.8,-0.9 1.4,-2 1.4,-3.3 0,-1.4 -0.6,-2.5 -1.4,-3.4 -1,-0.9 -2.2,-1.3 -3.7,-1.3h-9.2L127.8,6c0,-1.5 -0.5,-2.8 -1.5,-3.9 -1.1,-1 -2.4,-1.5 -4,-1.5 -1.5,0 -2.8,0.6 -3.8,1.5 -1,1.1 -1.5,2.4 -1.5,4v15.5zM473.6,78.7c-3.6,0 -5.4,-1.8 -5.4,-5.4L468.2,25c0,-3.6 1.8,-5.4 5.4,-5.4L497,19.6c4.3,0 7.5,0 9.6,1.5 2.2,1.4 3,3 2.5,5 -0.3,1.5 -1,2.5 -2.1,3s-2.4,0.5 -3.8,0.2c-4.6,-0.9 -8.8,-1 -12.4,-0.2 -3.7,0.8 -6.6,2.3 -8.8,4.4 -2,2.2 -3.1,5 -3.1,8.3v31.5c0,3.6 -1.8,5.4 -5.4,5.4zM428.5,78.6c-6,0 -11.3,-1.3 -16,-3.8a29,29 0,0 1,-10.9 -10.6,30.8 30.8,0 0,1 -3.9,-15.6c0,-5.9 1.3,-11 3.7,-15.5 2.5,-4.5 6,-8 10.4,-10.6 4.4,-2.6 9.4,-3.9 15,-3.9 5.7,0 10.5,1.3 14.6,3.8 4,2.4 7,5.9 9.1,10.3 2.2,4.3 3.3,9.3 3.3,15 0,1.3 -0.5,2.5 -1.4,3.4a5,5 0,0 1,-3.6 1.3h-41c0.8,5 3,9 6.8,12.2 3.8,3.1 8.4,4.7 14,4.7a22.2,22.2 0,0 0,12.5 -4.1c1,-0.8 2.2,-1.2 3.4,-1.2 1.3,0 2.4,0.3 3.3,1 1.3,1.1 2,2.3 2,3.6 0,1.3 -0.5,2.4 -1.7,3.4 -2.5,2 -5.6,3.5 -9.2,4.7 -3.6,1.2 -7,1.9 -10.4,1.9zM427,27.9c-5.3,0 -9.6,1.5 -12.9,4.4 -3.2,3 -5.3,6.8 -6.1,11.5h36c-0.7,-4.6 -2.5,-8.4 -5.3,-11.4 -3,-3 -6.8,-4.5 -11.7,-4.5zM360,21.6v36c0,4 0.8,7.6 2.5,10.7A17.8,17.8 0,0 0,379 78.6h2c1.8,0 3.3,-0.5 4.5,-1.6 1.2,-1 1.9,-2.2 1.9,-3.8 0,-1.6 -0.6,-2.9 -1.5,-4 -0.8,-1 -1.9,-1.5 -3.2,-1.5L379,67.7a7,7 0,0 1,-5.8 -2.9c-1.6,-2 -2.4,-4.3 -2.4,-7.2L370.8,30.9h9.2c1.5,0 2.7,-0.4 3.7,-1.3 0.8,-0.9 1.4,-2 1.4,-3.3 0,-1.4 -0.6,-2.5 -1.4,-3.4 -1,-0.9 -2.2,-1.3 -3.7,-1.3h-9.2L370.8,6c0,-1.5 -0.5,-2.8 -1.5,-3.9 -1,-1 -2.4,-1.5 -3.9,-1.5 -1.6,0 -3,0.6 -3.9,1.5 -1,1.1 -1.5,2.4 -1.5,4v15.5zM320.3,78.6c-5,0 -9.8,-0.8 -14,-2.3a27.4,27.4 0,0 1,-10 -6,4.8 4.8,0 0,1 -1.4,-3.8c0.2,-1.6 1,-2.8 2.1,-3.7 1.5,-1.2 2.9,-1.6 4.3,-1.4 1.4,0.1 2.6,0.7 3.6,1.8 1.2,1.4 3.2,2.7 5.9,4 2.7,1 5.7,1.6 9,1.6a18,18 0,0 0,9.5 -2c2.3,-1.4 3.4,-3.1 3.5,-5.3 0,-2.2 -1,-4 -3.1,-5.6 -2.1,-1.6 -6,-2.9 -11.6,-4a34.5,34.5 0,0 1,-15.9 -6.4,13.6 13.6,0 0,1 -4.8,-10.6c0,-3.6 1,-6.7 3.2,-9a19,19 0,0 1,8.3 -5.4,33 33,0 0,1 23.2,0.4c3.7,1.5 6.6,3.6 8.8,6.2a5,5 0,0 1,1.4 3.7c0,1.2 -0.7,2.3 -1.8,3.2 -1.2,0.7 -2.6,1 -4.1,0.7 -1.6,-0.3 -3,-1 -4,-2 -1.8,-1.7 -3.8,-2.9 -5.9,-3.5a24.4,24.4 0,0 0,-15.3 0.6c-2.2,1.1 -3.3,2.7 -3.3,4.8 0,1.3 0.4,2.5 1,3.6 0.7,1 2.1,2 4.1,2.8 2,0.8 5,1.6 8.9,2.3a34.5,34.5 0,0 1,16.8 6.9c3.2,2.8 4.8,6.4 4.8,10.6 0,3.4 -1,6.3 -2.7,9a17.8,17.8 0,0 1,-7.8 6.4,30 30,0 0,1 -12.7,2.4zM250.2,78.6c-5.2,0 -10,-1.3 -14.1,-4a29,29 0,0 1,-13.5 -26,29.3 29.3,0 0,1 29.8,-30A29,29 0,0 1,278 33.3c2.7,4.5 4,9.7 4,15.3v24.1c0,1.6 -0.5,3 -1.6,4 -1,1 -2.3,1.5 -4,1.5h-14.3c-3.7,0 -7.7,0.4 -12,0.4zM252.4,68.8c3.7,0 7,-0.8 9.8,-2.6 2.9,-1.8 5.2,-4.2 6.8,-7.2 1.7,-3 2.5,-6.5 2.5,-10.4 0,-3.8 -0.8,-7.3 -2.5,-10.3a18.3,18.3 0,0 0,-16.6 -10,19.1 19.1,0 0,0 -16.7,10c-1.7,3 -2.5,6.5 -2.5,10.3 0,3.9 0.8,7.3 2.5,10.4 1.7,3 4,5.4 6.9,7.2 3,1.8 6.2,2.6 9.8,2.6zM187.8,78.6a29.5,29.5 0,0 1,-26 -14.6,31 31,0 0,1 -3.8,-15.4c0,-5.8 1.3,-11 3.8,-15.5s6,-8 10.3,-10.6c4.4,-2.6 9.5,-3.9 15.2,-3.9 8.5,0 15.5,3.3 21.1,9.7 1,1.2 1.4,2.4 1.1,3.6 -0.3,1.2 -1,2.3 -2.4,3.2 -1,0.8 -2,1 -3.3,0.8a7,7 0,0 1,-3.4 -2c-3.5,-3.7 -7.9,-5.6 -13.1,-5.6 -5.6,0 -10,2 -13.5,5.7a20.6,20.6 0,0 0,-5.2 14.6c0,4 0.8,7.4 2.4,10.4 1.6,3 3.9,5.4 6.8,7.2 2.8,1.8 6.2,2.6 10,2.6a18,18 0,0 0,11.9 -3.7c1.1,-1 2.3,-1.5 3.6,-1.6a4,4 0,0 1,3.2 1c1.2,1 1.9,2.2 2,3.5a4,4 0,0 1,-1.3 3.3,27.4 27.4,0 0,1 -19.4,7.3z" + android:fillColor="#000"/> +</vector> 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 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#F27405" + android:pathData="M48.49,64.6c-1.52,0 -2.76,-0.68 -2.76,-1.5V36.2c0,-0.82 1.24,-1.5 2.76,-1.5 1.52,0 2.75,0.68 2.75,1.5V63.1c0,0.82 -1.23,1.5 -2.75,1.5zM37.47,55.63c-1.52,0 -2.76,-0.68 -2.76,-1.5v-8.97c0,-0.82 1.24,-1.5 2.76,-1.5 1.51,0 2.75,0.68 2.75,1.5v8.97c0,0.82 -1.24,1.5 -2.75,1.5zM59.51,58.62c-1.52,0 -2.75,-0.68 -2.75,-1.5V42.17c0,-0.82 1.23,-1.5 2.75,-1.5s2.76,0.68 2.76,1.5v14.95c0,0.82 -1.24,1.5 -2.76,1.5zM70.53,54.13c-1.51,0 -2.75,-0.67 -2.75,-1.5v-5.97c0,-0.83 1.24,-1.5 2.75,-1.5 1.52,0 2.76,0.67 2.76,1.5v5.98c0,0.82 -1.24,1.5 -2.76,1.5z" /> + <path + android:fillColor="#FF9F0C" + android:pathData="M48.49,68.62c-1.52,0 -2.76,-0.68 -2.76,-1.5V40.21c0,-0.82 1.24,-1.5 2.76,-1.5 1.52,0 2.75,0.68 2.75,1.5v26.91c0,0.82 -1.23,1.5 -2.75,1.5zM37.47,59.65c-1.52,0 -2.76,-0.68 -2.76,-1.5v-8.97c0,-0.82 1.24,-1.5 2.76,-1.5 1.51,0 2.75,0.68 2.75,1.5v8.97c0,0.82 -1.24,1.5 -2.75,1.5zM59.51,62.64c-1.52,0 -2.75,-0.68 -2.75,-1.5V46.19c0,-0.82 1.23,-1.5 2.75,-1.5s2.76,0.68 2.76,1.5v14.95c0,0.82 -1.24,1.5 -2.76,1.5zM70.53,58.68c-1.51,0 -2.75,-0.71 -2.75,-1.58v-6.34c0,-0.87 1.24,-1.58 2.75,-1.58 1.52,0 2.76,0.71 2.76,1.58v6.34c0,0.87 -1.24,1.58 -2.76,1.58z" /> + <path + android:fillColor="#FFD083" + android:pathData="M48.49,73.27c-1.52,0 -2.76,-0.6 -2.76,-1.34V47.9c0,-0.73 1.24,-1.34 2.76,-1.34 1.51,0 2.75,0.6 2.75,1.34v24.03c0,0.74 -1.24,1.34 -2.75,1.34zM37.47,65.26c-1.52,0 -2.76,-0.6 -2.76,-1.34v-8c0,-0.74 1.24,-1.34 2.76,-1.34 1.51,0 2.75,0.6 2.75,1.33v8.01c0,0.74 -1.24,1.34 -2.75,1.34zM59.5,67.93c-1.5,0 -2.75,-0.6 -2.75,-1.34V53.24c0,-0.73 1.24,-1.33 2.76,-1.33 1.51,0 2.75,0.6 2.75,1.33v13.35c0,0.74 -1.24,1.34 -2.75,1.34zM70.52,63.92c-1.51,0 -2.75,-0.6 -2.75,-1.33v-5.34c0,-0.74 1.24,-1.34 2.75,-1.34 1.52,0 2.76,0.6 2.76,1.34v5.34c0,0.73 -1.24,1.33 -2.76,1.33z" /> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#121212" + android:pathData="M0,0h108v108h-108z" /> + <path + android:fillColor="#00000000" + android:pathData="M9,0L9,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,0L19,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,0L29,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,0L39,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,0L49,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,0L59,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,0L69,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,0L79,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M89,0L89,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M99,0L99,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,9L108,9" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,19L108,19" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,29L108,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,39L108,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,49L108,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,59L108,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,69L108,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,79L108,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,89L108,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,99L108,99" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,29L89,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,39L89,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,49L89,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,59L89,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,69L89,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,79L89,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,19L29,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,19L39,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,19L49,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,19L59,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,19L69,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,19L79,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> +</vector> 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 @@ +<!-- + ~ 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <group android:scaleX="0.9182609" + android:scaleY="0.9182609" + android:translateX="4.4139132" + android:translateY="4.4139132"> + <group> + <clip-path + android:pathData="M31.5,76.63l45.12,-0l0,-45.13l-45.12,-0z"/> + <path + android:pathData="M47.62,66.48C45.84,66.48 44.39,65.69 44.39,64.73V33.25C44.39,32.29 45.84,31.5 47.62,31.5C49.39,31.5 50.84,32.29 50.84,33.25V64.73C50.84,65.69 49.39,66.48 47.62,66.48Z" + android:fillColor="#F27405"/> + <path + android:pathData="M34.72,55.99C32.95,55.99 31.5,55.2 31.5,54.24V43.74C31.5,42.78 32.95,41.99 34.72,41.99C36.5,41.99 37.95,42.78 37.95,43.74V54.24C37.95,55.2 36.5,55.99 34.72,55.99Z" + android:fillColor="#F27405"/> + <path + android:pathData="M60.51,59.49C58.74,59.49 57.28,58.7 57.28,57.74V40.25C57.28,39.28 58.74,38.5 60.51,38.5C62.28,38.5 63.73,39.28 63.73,40.25V57.74C63.73,58.7 62.28,59.49 60.51,59.49Z" + android:fillColor="#F27405"/> + <path + android:pathData="M73.4,54.24C71.63,54.24 70.18,53.45 70.18,52.49V45.49C70.18,44.53 71.63,43.74 73.4,43.74C75.17,43.74 76.62,44.53 76.62,45.49V52.49C76.62,53.45 75.17,54.24 73.4,54.24Z" + android:fillColor="#F27405"/> + <path + android:pathData="M47.62,71.18C45.84,71.18 44.39,70.39 44.39,69.43V37.95C44.39,36.99 45.84,36.2 47.62,36.2C49.39,36.2 50.84,36.99 50.84,37.95V69.43C50.84,70.39 49.39,71.18 47.62,71.18Z" + android:fillColor="#FF9F0C"/> + <path + android:pathData="M34.72,60.69C32.95,60.69 31.5,59.9 31.5,58.94V48.44C31.5,47.48 32.95,46.69 34.72,46.69C36.5,46.69 37.95,47.48 37.95,48.44V58.94C37.95,59.9 36.5,60.69 34.72,60.69Z" + android:fillColor="#FF9F0C"/> + <path + android:pathData="M60.51,64.19C58.74,64.19 57.28,63.4 57.28,62.44V44.95C57.28,43.98 58.74,43.2 60.51,43.2C62.28,43.2 63.73,43.98 63.73,44.95V62.44C63.73,63.4 62.28,64.19 60.51,64.19Z" + android:fillColor="#FF9F0C"/> + <path + android:pathData="M73.4,58.94C71.63,58.94 70.18,58.15 70.18,57.19V50.19C70.18,49.23 71.63,48.44 73.4,48.44C75.17,48.44 76.62,49.23 76.62,50.19V57.19C76.62,58.15 75.17,58.94 73.4,58.94Z" + android:fillColor="#FF9F0C"/> + <path + android:pathData="M47.61,76.63C45.84,76.63 44.39,75.92 44.39,75.06V46.95C44.39,46.09 45.84,45.39 47.61,45.39C49.39,45.39 50.84,46.09 50.84,46.95V75.06C50.84,75.92 49.39,76.63 47.61,76.63Z" + android:fillColor="#FFD083"/> + <path + android:pathData="M34.72,67.25C32.95,67.25 31.5,66.55 31.5,65.69V56.32C31.5,55.46 32.95,54.76 34.72,54.76C36.49,54.76 37.95,55.46 37.95,56.32V65.69C37.95,66.55 36.49,67.25 34.72,67.25Z" + android:fillColor="#FFD083"/> + <path + android:pathData="M60.5,70.38C58.73,70.38 57.28,69.67 57.28,68.82V53.2C57.28,52.34 58.73,51.63 60.5,51.63C62.28,51.63 63.73,52.34 63.73,53.2V68.82C63.73,69.67 62.28,70.38 60.5,70.38Z" + android:fillColor="#FFD083"/> + <path + android:pathData="M73.39,65.69C71.62,65.69 70.17,64.99 70.17,64.13V57.88C70.17,57.02 71.62,56.32 73.39,56.32C75.17,56.32 76.62,57.02 76.62,57.88V64.13C76.62,64.99 75.17,65.69 73.39,65.69Z" + android:fillColor="#FFD083"/> + </group> + </group> +</vector> 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 @@ +<!-- + ~ 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <group> + <clip-path + android:pathData="M26.25,81.75l55.5,-0l0,-55.5l-55.5,-0z"/> + <path + android:pathData="M46.07,69.27C43.89,69.27 42.11,68.31 42.11,67.12V28.4C42.11,27.22 43.89,26.25 46.07,26.25C48.25,26.25 50.03,27.22 50.03,28.4V67.12C50.03,68.31 48.25,69.27 46.07,69.27Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M30.21,56.37C28.03,56.37 26.25,55.4 26.25,54.21V41.31C26.25,40.12 28.03,39.16 30.21,39.16C32.39,39.16 34.18,40.12 34.18,41.31V54.21C34.18,55.4 32.39,56.37 30.21,56.37Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M61.93,60.67C59.75,60.67 57.96,59.7 57.96,58.52V37.01C57.96,35.82 59.75,34.85 61.93,34.85C64.11,34.85 65.89,35.82 65.89,37.01V58.52C65.89,59.7 64.11,60.67 61.93,60.67Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M77.78,54.21C75.6,54.21 73.82,53.25 73.82,52.06V43.46C73.82,42.28 75.6,41.31 77.78,41.31C79.96,41.31 81.75,42.28 81.75,43.46V52.06C81.75,53.25 79.96,54.21 77.78,54.21Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M46.07,75.05C43.89,75.05 42.11,74.09 42.11,72.9V34.18C42.11,33 43.89,32.03 46.07,32.03C48.25,32.03 50.03,33 50.03,34.18V72.9C50.03,74.09 48.25,75.05 46.07,75.05Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M30.21,62.15C28.03,62.15 26.25,61.18 26.25,60V47.09C26.25,45.91 28.03,44.94 30.21,44.94C32.39,44.94 34.18,45.91 34.18,47.09V60C34.18,61.18 32.39,62.15 30.21,62.15Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M61.93,66.45C59.75,66.45 57.96,65.48 57.96,64.3V42.79C57.96,41.6 59.75,40.64 61.93,40.64C64.11,40.64 65.89,41.6 65.89,42.79V64.3C65.89,65.48 64.11,66.45 61.93,66.45Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M77.78,60C75.6,60 73.82,59.03 73.82,57.84V49.24C73.82,48.06 75.6,47.09 77.78,47.09C79.96,47.09 81.75,48.06 81.75,49.24V57.84C81.75,59.03 79.96,60 77.78,60Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M46.07,81.75C43.89,81.75 42.1,80.89 42.1,79.83V45.25C42.1,44.19 43.89,43.33 46.07,43.33C48.25,43.33 50.03,44.19 50.03,45.25V79.83C50.03,80.89 48.25,81.75 46.07,81.75Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M30.21,70.22C28.03,70.22 26.25,69.36 26.25,68.3V56.78C26.25,55.72 28.03,54.85 30.21,54.85C32.39,54.85 34.18,55.72 34.18,56.78V68.3C34.18,69.36 32.39,70.22 30.21,70.22Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M61.92,74.07C59.74,74.07 57.96,73.2 57.96,72.14V52.93C57.96,51.88 59.74,51.01 61.92,51.01C64.1,51.01 65.88,51.88 65.88,52.93V72.14C65.88,73.2 64.1,74.07 61.92,74.07Z" + android:fillColor="#171D1A"/> + <path + android:pathData="M77.78,68.3C75.6,68.3 73.81,67.44 73.81,66.38V58.7C73.81,57.64 75.6,56.78 77.78,56.78C79.96,56.78 81.74,57.64 81.74,58.7V66.38C81.74,67.44 79.96,68.3 77.78,68.3Z" + android:fillColor="#171D1A"/> + </group> +</vector> 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 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#F27405" + android:pathData="M8.5,19c-0.83,0 -1.5,-0.43 -1.5,-0.95V0.95C7,0.43 7.67,0 8.5,0c0.82,0 1.5,0.43 1.5,0.95v17.1c0,0.52 -0.68,0.95 -1.5,0.95zM1.5,13c-0.83,0 -1.5,-0.4 -1.5,-0.88V6.88C0,6.39 0.67,6 1.5,6s1.5,0.4 1.5,0.88v5.24c0,0.49 -0.67,0.88 -1.5,0.88zM15.5,15c-0.82,0 -1.5,-0.41 -1.5,-0.92V4.92c0,-0.5 0.68,-0.92 1.5,-0.92 0.83,0 1.5,0.41 1.5,0.92v9.16c0,0.5 -0.67,0.92 -1.5,0.92zM22.5,12c-0.83,0 -1.5,-0.45 -1.5,-1V7c0,-0.55 0.67,-1 1.5,-1 0.82,0 1.5,0.45 1.5,1v4c0,0.55 -0.68,1 -1.5,1z" /> + <path + android:fillColor="#FF9F0C" + android:pathData="M8.5,21c-0.83,0 -1.5,-0.43 -1.5,-0.95V2.95C7,2.43 7.67,2 8.5,2c0.82,0 1.5,0.43 1.5,0.95v17.1c0,0.52 -0.68,0.95 -1.5,0.95zM1.5,15c-0.83,0 -1.5,-0.4 -1.5,-0.88V8.87C0,8.4 0.67,8 1.5,8s1.5,0.4 1.5,0.87v5.25c0,0.49 -0.67,0.88 -1.5,0.88zM15.5,17c-0.82,0 -1.5,-0.41 -1.5,-0.92V6.92c0,-0.5 0.68,-0.92 1.5,-0.92 0.83,0 1.5,0.41 1.5,0.92v9.16c0,0.5 -0.67,0.92 -1.5,0.92zM22.5,15c-0.83,0 -1.5,-0.45 -1.5,-1v-4c0,-0.55 0.67,-1 1.5,-1 0.82,0 1.5,0.45 1.5,1v4c0,0.55 -0.68,1 -1.5,1z" /> + <path + android:fillColor="#FFD083" + android:pathData="M8.5,24c-0.83,0 -1.5,-0.38 -1.5,-0.85V7.85C7,7.38 7.67,7 8.5,7s1.5,0.38 1.5,0.85v15.3c0,0.47 -0.67,0.85 -1.5,0.85zM1.5,19c-0.82,0 -1.5,-0.4 -1.5,-0.87v-5.26C0,12.4 0.68,12 1.5,12c0.83,0 1.5,0.4 1.5,0.87v5.26c0,0.48 -0.67,0.87 -1.5,0.87zM15.5,21c-0.83,0 -1.5,-0.38 -1.5,-0.83v-8.34c0,-0.45 0.67,-0.83 1.5,-0.83 0.82,0 1.5,0.38 1.5,0.83v8.34c0,0.45 -0.68,0.83 -1.5,0.83zM22.5,18c-0.82,0 -1.5,-0.38 -1.5,-0.83v-3.34c0,-0.45 0.68,-0.83 1.5,-0.83 0.83,0 1.5,0.38 1.5,0.83v3.34c0,0.45 -0.67,0.83 -1.5,0.83z" /> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background"/> + <foreground android:drawable="@drawable/ic_launcher_foreground"/> +</adaptive-icon> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background"/> + <foreground android:drawable="@drawable/ic_launcher_foreground"/> +</adaptive-icon> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<resources> + <color name="ic_launcher_background">#121212</color> +</resources> 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 @@ +<!-- + 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. +--> + +<resources> + <string name="app_name">JetCaster</string> + <string name="message_not_available_feature">This feature is not available yet.</string> + <string name="message_loading">Loading</string> + <string name="display_no_subscribed_podcast">Let\'s discover the podcasts!</string> + <string name="message_no_subscribed_podcast">You subscribe no podcast yet. Let\'s discover the podcasts and subscribe them!</string> + <string name="display_error_state">Something wrong happened</string> + <string name="display_nothing_in_queue">No episode in the queue</string> + <string name="message_nothing_in_queue">Discover the Podcast you want to listen to</string> + <string name="section_podcast">Podcast</string> + <string name="section_latest_episodes">Latest Episodes</string> + <string name="label_subscribe">Subscribe</string> + <string name="label_unsubscribe">Subscribed</string> + <string name="label_info">Info</string> + <string name="label_play">Play</string> + <string name="label_pause">Pause</string> + <string name="label_skip">Skip 10 seconds</string> + <string name="label_rewind">Rewind 10 seconds</string> + <string name="label_next_episode">Play the next episode</string> + <string name="label_previous_episode">Play the previous episode</string> + <string name="label_listen">Listen</string> + <string name="label_podcast">Podcasts</string> + <string name="label_episode">Episodes</string> + <string name="label_latest_episode">Latest Episodes</string> + <string name="label_navigate_to_discover">Discover the podcasts</string> + <string name="label_back_to_home">Back to Home</string> + <string name="label_search">Search podcasts by keyword</string> + <string name="label_add_playlist">Add to playlist</string> + + <string name="updated_longer">Updated a while ago</string> + <plurals name="updated_weeks_ago"> + <item quantity="one">Updated %d week ago</item> + <item quantity="other">Updated %d weeks ago</item> + </plurals> + <plurals name="updated_days_ago"> + <item quantity="one">Updated yesterday</item> + <item quantity="other">Updated %d days ago</item> + </plurals> + <string name="updated_today">Updated today</string> + + <string name="episode_date_duration">%1$s • %2$d mins</string> + <string name="elapsed_time">%1$s • %2$s</string> + <string name="minutes_seconds">%1$02d:%2$02d</string> +</resources> \ 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 @@ +<!-- + 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. +--> + +<resources> + <style name="Theme.Jetcaster" parent="Theme.AppCompat.DayNight.NoActionBar" /> +</resources> \ No newline at end of file diff --git a/Crane/app/.gitignore b/Jetcaster/wear/.gitignore similarity index 100% rename from Crane/app/.gitignore rename to Jetcaster/wear/.gitignore diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle new file mode 100644 index 0000000000..67b9975aa6 --- /dev/null +++ b/Jetcaster/wear/build.gradle @@ -0,0 +1,155 @@ +/* + * 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. + */ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias libs.plugins.roborazzi + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) + alias(libs.plugins.compose) +} + +android { + compileSdk 35 + + namespace "com.example.jetcaster" + + defaultConfig { + applicationId "com.example.jetcaster" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + signingConfig signingConfigs.debug + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + coreLibraryDesugaringEnabled true + } + testOptions { + unitTests { + includeAndroidResources true + } + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.majorVersion + freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.RequiresOptIn" + freeCompilerArgs = freeCompilerArgs + "-opt-in=com.google.android.horologist.annotations.ExperimentalHorologistApi" + } + buildFeatures { + compose true + } + + packagingOptions { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "rome-utils-" + libs.rometools.rome.get().version + ".jar" + } + } +} + +dependencies { + + + def composeBom = platform(libs.androidx.compose.bom) + + // General compose dependencies + implementation composeBom + implementation libs.androidx.activity.compose + implementation libs.androidx.core.splashscreen + + // Compose for Wear OS Dependencies + // NOTE: DO NOT INCLUDE a dependency on androidx.compose.material:material. + // androidx.wear.compose:compose-material is designed as a replacement not an addition to + // androidx.compose.material:material. If there are features from that you feel are missing from + // androidx.wear.compose:compose-material please raise a bug to let us know: + // https://issuetracker.google.com/issues/new?component=1077552&template=1598429&pli=1 + implementation libs.androidx.wear.compose.material + + implementation(libs.kotlinx.collections.immutable) + + // Foundation is additive, so you can use the mobile version in your Wear OS app. + implementation libs.androidx.wear.compose.foundation + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.compose.material.iconsExtended) + + // Horologist for correct Compose layout + implementation libs.horologist.composables + implementation libs.horologist.compose.layout + implementation libs.horologist.compose.material + + //Horologist Media toolkit + implementation libs.horologist.media.ui + implementation libs.horologist.audio.ui + implementation libs.horologist.media.data + implementation libs.horologist.images.coil + + // Dependency injection + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + // Preview Tooling + implementation libs.androidx.compose.ui.tooling.preview + implementation(libs.androidx.compose.ui.tooling) + implementation libs.androidx.wear.compose.ui.tooling + + // If you are using Compose Navigation, use the Wear OS version (NOT the + // androidx.navigation:navigation-compose version), that is, uncomment the line below. + implementation libs.androidx.wear.compose.navigation + + implementation libs.androidx.compose.ui.test.manifest + + implementation(libs.coil.kt.compose) + + coreLibraryDesugaring(libs.core.jdk.desugaring) + + implementation projects.core.data + implementation projects.core.designsystem + implementation projects.core.domain + implementation projects.core.domainTesting + + // Testing + testImplementation libs.androidx.compose.ui.test.junit4 + testImplementation libs.junit + testImplementation libs.robolectric + testImplementation libs.roborazzi + testImplementation libs.roborazzi.compose + testImplementation libs.roborazzi.rule + testImplementation(libs.horologist.roboscreenshots) { + exclude(group: "com.github.QuickBirdEng.kotlin-snapshot-testing") + } + + androidTestImplementation libs.androidx.test.ext.junit + androidTestImplementation libs.androidx.test.espresso.core + androidTestImplementation libs.androidx.compose.ui.test.junit4 + androidTestImplementation composeBom + + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation libs.androidx.compose.ui.test.manifest + debugImplementation composeBom +} diff --git a/Jetcaster/wear/proguard-rules.pro b/Jetcaster/wear/proguard-rules.pro new file mode 100644 index 0000000000..2050fceabe --- /dev/null +++ b/Jetcaster/wear/proguard-rules.pro @@ -0,0 +1,39 @@ +# Copyright (C) 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. +# +# 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 + +##---------------Begin: proguard configuration for Pusher Java Client ---------- +-dontwarn org.slf4j.impl.StaticLoggerBinder +##---------------End: proguard configuration for Pusher Java Client ---------- \ No newline at end of file diff --git a/Jetcaster/wear/src/main/AndroidManifest.xml b/Jetcaster/wear/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..ccde4d9b03 --- /dev/null +++ b/Jetcaster/wear/src/main/AndroidManifest.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 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 + 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. +--> +<manifest + xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.WAKE_LOCK" /> + + <uses-feature android:name="android.hardware.type.watch" /> + + <application + android:name=".JetcasterWearApplication" + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@android:style/Theme.DeviceDefault"> + <uses-library + android:name="com.google.android.wearable" + android:required="true" /> + <!-- + Set to true if your app is Standalone, that is, it does not require the handheld + app to run. + --> + <meta-data + android:name="com.google.android.wearable.standalone" + android:value="true" /> + + <activity + android:name=".MainActivity" + android:exported="true" + android:label="@string/app_name" + android:theme="@style/Theme.App.Starting"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/JetcasterWearApplication.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/JetcasterWearApplication.kt new file mode 100644 index 0000000000..a633b967a8 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/JetcasterWearApplication.kt @@ -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. + */ + +package com.example.jetcaster + +import android.app.Application +import android.os.StrictMode +import coil.ImageLoader +import coil.ImageLoaderFactory +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +@HiltAndroidApp +class JetcasterWearApplication : Application(), ImageLoaderFactory { + + @Inject lateinit var imageLoader: ImageLoader + + override fun onCreate() { + super.onCreate() + setStrictMode() + } + + private fun setStrictMode() { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectNetwork() + .penaltyLog() + .build(), + ) + } + + override fun newImageLoader(): ImageLoader = + imageLoader +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt new file mode 100644 index 0000000000..64577ff932 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.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.jetcaster + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.navigation.NavHostController +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + lateinit var navController: NavHostController + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + + setContent { + navController = rememberSwipeDismissableNavController() + + WearApp(navController) + } + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt new file mode 100644 index 0000000000..997fea950f --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -0,0 +1,165 @@ +/* + * 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 + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState +import com.example.jetcaster.theme.WearAppTheme +import com.example.jetcaster.ui.Episode +import com.example.jetcaster.ui.JetcasterNavController.navigateToEpisode +import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode +import com.example.jetcaster.ui.JetcasterNavController.navigateToPodcastDetails +import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext +import com.example.jetcaster.ui.JetcasterNavController.navigateToYourPodcast +import com.example.jetcaster.ui.LatestEpisodes +import com.example.jetcaster.ui.PodcastDetails +import com.example.jetcaster.ui.UpNext +import com.example.jetcaster.ui.YourPodcasts +import com.example.jetcaster.ui.episode.EpisodeScreen +import com.example.jetcaster.ui.latest_episodes.LatestEpisodesScreen +import com.example.jetcaster.ui.library.LibraryScreen +import com.example.jetcaster.ui.player.PlayerScreen +import com.example.jetcaster.ui.podcast.PodcastDetailsScreen +import com.example.jetcaster.ui.podcasts.PodcastsScreen +import com.example.jetcaster.ui.queue.QueueScreen +import com.google.android.horologist.audio.ui.VolumeScreen +import com.google.android.horologist.audio.ui.VolumeViewModel +import com.google.android.horologist.compose.layout.AppScaffold +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer +import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToVolume +import com.google.android.horologist.media.ui.navigation.NavigationScreens +import com.google.android.horologist.media.ui.screens.playerlibrarypager.PlayerLibraryPagerScreen + +@Composable +fun WearApp(navController: NavHostController) { + val navHostState = rememberSwipeDismissableNavHostState() + val volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory) + + WearAppTheme { + AppScaffold { + SwipeDismissableNavHost( + startDestination = NavigationScreens.Player.playerDestination(), + navController = navController, + modifier = Modifier.background(Color.Transparent), + state = navHostState, + ) { + composable( + route = NavigationScreens.Player.navRoute, + arguments = NavigationScreens.Player.arguments, + deepLinks = NavigationScreens.Player.deepLinks(""), + ) { + val volumeState by volumeViewModel.volumeUiState.collectAsStateWithLifecycle() + val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 }) + + PlayerLibraryPagerScreen( + pagerState = pagerState, + volumeUiState = { volumeState }, + displayVolumeIndicatorEvents = volumeViewModel.displayIndicatorEvents, + playerScreen = { + PlayerScreen( + modifier = Modifier.fillMaxSize(), + volumeViewModel = volumeViewModel, + onVolumeClick = { + navController.navigateToVolume() + } + ) + }, + libraryScreen = { + LibraryScreen( + onLatestEpisodeClick = { navController.navigateToLatestEpisode() }, + onYourPodcastClick = { navController.navigateToYourPodcast() }, + onUpNextClick = { navController.navigateToUpNext() }, + ) + }, + backStack = it, + ) + } + + composable( + route = NavigationScreens.Volume.navRoute, + arguments = NavigationScreens.Volume.arguments, + deepLinks = NavigationScreens.Volume.deepLinks(""), + ) { + ScreenScaffold(timeText = {}) { + VolumeScreen(volumeViewModel = volumeViewModel) + } + } + + composable( + route = LatestEpisodes.navRoute, + ) { + LatestEpisodesScreen( + onPlayButtonClick = { + navController.navigateToPlayer() + }, + onDismiss = { navController.popBackStack() } + ) + } + composable(route = YourPodcasts.navRoute) { + PodcastsScreen( + onPodcastsItemClick = { navController.navigateToPodcastDetails(it.uri) }, + onDismiss = { navController.popBackStack() } + ) + } + composable(route = PodcastDetails.navRoute) { + PodcastDetailsScreen( + onPlayButtonClick = { + navController.navigateToPlayer() + }, + onEpisodeItemClick = { navController.navigateToEpisode(it.uri) }, + onDismiss = { navController.popBackStack() } + ) + } + composable(route = UpNext.navRoute) { + QueueScreen( + onPlayButtonClick = { + navController.navigateToPlayer() + }, + onEpisodeItemClick = { navController.navigateToPlayer() }, + onDismiss = { + navController.popBackStack() + navController.navigateToYourPodcast() + } + ) + } + composable(route = Episode.navRoute) { + EpisodeScreen( + onPlayButtonClick = { + navController.navigateToPlayer() + }, + onDismiss = { + navController.popBackStack() + navController.navigateToYourPodcast() + } + ) + } + } + } + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Color.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Color.kt new file mode 100644 index 0000000000..2bb7c18ffa --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Color.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.theme + +import androidx.wear.compose.material.Colors +import com.example.jetcaster.designsystem.theme.errorDark +import com.example.jetcaster.designsystem.theme.onErrorDark +import com.example.jetcaster.designsystem.theme.onPrimaryDark +import com.example.jetcaster.designsystem.theme.onSecondaryDark +import com.example.jetcaster.designsystem.theme.primaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.primaryDark +import com.example.jetcaster.designsystem.theme.secondaryContainerDarkMediumContrast +import com.example.jetcaster.designsystem.theme.secondaryDark + +internal val wearColorPalette: Colors = Colors( + primary = primaryDark, + primaryVariant = primaryContainerDarkMediumContrast, + secondary = secondaryDark, + secondaryVariant = secondaryContainerDarkMediumContrast, + error = errorDark, + onPrimary = onPrimaryDark, + onSecondary = onSecondaryDark, + onError = onErrorDark +) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Type.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Type.kt new file mode 100644 index 0000000000..2ce188903a --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/Type.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Typography +import com.example.jetcaster.designsystem.theme.Montserrat + +// Set of Material typography styles to start with +val Typography = Typography( + body1 = TextStyle( + fontFamily = Montserrat, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ) + /* Other default text styles to override + button = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + fontSize = 14.sp + ), + caption = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ) + */ +) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt new file mode 100644 index 0000000000..c797a8c041 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/theme/WearAppTheme.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.theme + +import androidx.compose.runtime.Composable +import androidx.wear.compose.material.MaterialTheme + +@Composable +fun WearAppTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colors = wearColorPalette, + typography = Typography, + // For shapes, we generally recommend using the default Material Wear shapes which are + // optimized for round and non-round devices. + content = content + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.kt new file mode 100644 index 0000000000..673eb527e2 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/JetcasterNavController.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.ui + +import android.net.Uri +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavController +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.google.android.horologist.media.ui.navigation.NavigationScreens + +/** + * NavController extensions that links to the screens of the Jetcaster app. + */ +public object JetcasterNavController { + + public fun NavController.navigateToYourPodcast() { + navigate(YourPodcasts.destination()) + } + + public fun NavController.navigateToLatestEpisode() { + navigate(LatestEpisodes.destination()) + } + + public fun NavController.navigateToPodcastDetails(podcastUri: String) { + navigate(PodcastDetails.destination(podcastUri)) + } + + public fun NavController.navigateToUpNext() { + navigate(UpNext.destination()) + } + + public fun NavController.navigateToEpisode(episodeUri: String) { + navigate(Episode.destination(episodeUri)) + } +} + +public object YourPodcasts : NavigationScreens("yourPodcasts") { + public fun destination(): String = navRoute +} + +public object LatestEpisodes : NavigationScreens("latestEpisodes") { + public fun destination(): String = navRoute +} + +public object PodcastDetails : NavigationScreens("podcast?podcastUri={podcastUri}") { + public const val PODCAST_URI: String = "podcastUri" + public fun destination(podcastUri: String): String { + val encodedUri = Uri.encode(podcastUri) + return "podcast?$PODCAST_URI=$encodedUri" + } + + override val arguments: List<NamedNavArgument> + get() = listOf( + navArgument(PODCAST_URI) { + type = NavType.StringType + }, + ) +} + +public object Episode : NavigationScreens("episode?episodeUri={episodeUri}") { + public const val EPISODE_URI: String = "episodeUri" + public fun destination(episodeUri: String): String { + val encodedUri = Uri.encode(episodeUri) + return "episode?$EPISODE_URI=$encodedUri" + } + + override val arguments: List<NamedNavArgument> + get() = listOf( + navArgument(EPISODE_URI) { + type = NavType.StringType + }, + ) +} + +public object UpNext : NavigationScreens("upNext") { + public fun destination(): String = navRoute +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt new file mode 100644 index 0000000000..fe8b33c1d7 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/MediaContent.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.stringResource +import androidx.wear.compose.material.ChipDefaults +import com.example.jetcaster.R +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.images.coil.CoilPaintable +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun MediaContent( + episode: PlayerEpisode, + episodeArtworkPlaceholder: Painter?, + onItemClick: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier +) { + val mediaTitle = episode.title + val duration = episode.duration + + val secondaryLabel = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(episode.published), + duration.toMinutes().toInt() + ) + } + // Otherwise we just use the date + else -> MediumDateFormatter.format(episode.published) + } + + Chip( + label = mediaTitle, + onClick = { onItemClick(episode) }, + secondaryLabel = secondaryLabel, + icon = CoilPaintable(episode.podcastImageUrl, episodeArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + modifier = modifier + ) +} + +public val MediumDateFormatter: DateTimeFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.kt new file mode 100644 index 0000000000..91f32debc5 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/components/SettingsButtons.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.jetcaster.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import com.example.jetcaster.R +import com.example.jetcaster.ui.player.PlayerUiState +import com.google.android.horologist.audio.ui.VolumeUiState +import com.google.android.horologist.audio.ui.components.SettingsButtonsDefaults +import com.google.android.horologist.audio.ui.components.actions.SetVolumeButton +import com.google.android.horologist.audio.ui.components.actions.SettingsButton +import com.google.android.horologist.compose.material.IconRtlMode + +/** + * Settings buttons for the Jetcaster media app. + * Add to queue and Set Volume. + */ +@Composable +fun SettingsButtons( + volumeUiState: VolumeUiState, + onVolumeClick: () -> Unit, + playerUiState: PlayerUiState, + onPlaybackSpeedChange: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + modifier = modifier.fillMaxWidth(0.8124f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + PlaybackSpeedButton( + currentPlayerSpeed = playerUiState.episodePlayerState + .playbackSpeed.toMillis().toFloat() / 1000, + onPlaybackSpeedChange = onPlaybackSpeedChange, + enabled = enabled + ) + + SettingsButtonsDefaults.BrandIcon( + iconId = R.drawable.ic_logo, + enabled = enabled, + ) + + SetVolumeButton( + onVolumeClick = onVolumeClick, + volumeUiState = volumeUiState, + enabled = enabled + ) + } +} + +@Composable +fun PlaybackSpeedButton( + currentPlayerSpeed: Float, + onPlaybackSpeedChange: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + SettingsButton( + modifier = modifier, + onClick = onPlaybackSpeedChange, + enabled = enabled, + imageVector = + when (currentPlayerSpeed) { + 1f -> ImageVector.vectorResource(R.drawable.speed_1x) + 1.5f -> ImageVector.vectorResource(R.drawable.speed_15x) + else -> { ImageVector.vectorResource(R.drawable.speed_2x) } + }, + iconRtlMode = IconRtlMode.Mirrored, + contentDescription = stringResource(R.string.change_playback_speed_content_description), + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt new file mode 100644 index 0000000000..d03b8fb2dd --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeScreen.kt @@ -0,0 +1,289 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.episode + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.ScalingLazyListScope +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.ExperimentalWearMaterialApi +import androidx.wear.compose.material.LocalContentColor +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import com.example.jetcaster.R +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.core.player.model.toPlayerEpisode +import com.example.jetcaster.designsystem.component.HtmlTextContainer +import com.example.jetcaster.ui.components.MediumDateFormatter +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.listTextPadding +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.AlertDialog +import com.google.android.horologist.compose.material.Button +import com.google.android.horologist.compose.material.ListHeaderDefaults +import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@Composable +fun EpisodeScreen( + onPlayButtonClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + episodeViewModel: EpisodeViewModel = hiltViewModel() +) { + val uiState by episodeViewModel.uiState.collectAsStateWithLifecycle() + + EpisodeScreen( + uiState = uiState, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = episodeViewModel::onPlayEpisode, + onAddToQueue = episodeViewModel::addToQueue, + onDismiss = onDismiss, + modifier = modifier, + ) +} + +@Composable +fun EpisodeScreen( + uiState: EpisodeScreenState, + onPlayButtonClick: () -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit, + onAddToQueue: (PlayerEpisode) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + ScreenScaffold( + scrollState = columnState, + modifier = modifier + ) { + when (uiState) { + is EpisodeScreenState.Loaded -> { + val title = uiState.episode.episode.title + + EntityScreen( + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + buttonsContent = { + LoadedButtonsContent( + episode = uiState.episode, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode, + onAddToQueue = onAddToQueue + ) + }, + content = { + episodeInfoContent(episode = uiState.episode) + } + + ) + } + + EpisodeScreenState.Empty -> { + AlertDialog( + showDialog = true, + onDismiss = { onDismiss }, + message = stringResource(R.string.episode_info_not_available) + ) + } + EpisodeScreenState.Loading -> { + LoadingScreen() + } + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun LoadedButtonsContent( + episode: EpisodeToPodcast, + onPlayButtonClick: () -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit, + onAddToQueue: (PlayerEpisode) -> Unit, + enabled: Boolean = true +) { + + Row( + modifier = Modifier + .padding(bottom = 16.dp) + .height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + + Button( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = stringResource(id = R.string.button_play_content_description), + onClick = { + onPlayButtonClick() + onPlayEpisode(episode.toPlayerEpisode()) + }, + enabled = enabled, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + + Button( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(id = R.string.add_to_queue_content_description), + onClick = { onAddToQueue(episode.toPlayerEpisode()) }, + enabled = enabled, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + } +} +@OptIn(ExperimentalWearMaterialApi::class) +@Composable +fun LoadingScreen() { + EntityScreen( + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(R.string.loading)) + } + }, + buttonsContent = { + LoadingButtonsContent() + }, + content = { + items(count = 2) { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } + ) +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun LoadingButtonsContent() { + Row( + modifier = Modifier + .padding(bottom = 16.dp) + .height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + + Button( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = stringResource(id = R.string.button_play_content_description), + onClick = {}, + enabled = false, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + + Button( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(id = R.string.add_to_queue_content_description), + onClick = {}, + enabled = false, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + ) + } +} + +private fun ScalingLazyListScope.episodeInfoContent(episode: EpisodeToPodcast) { + val author = episode.episode.author + val duration = episode.episode.duration + val published = episode.episode.published + val summary = episode.episode.summary + + if (!author.isNullOrEmpty()) { + item { + Text( + text = author, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + } + } + + item { + Text( + text = when { + duration != null -> { + // If we have the duration, we combine the date/duration via a + // formatted string + stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(published), + duration.toMinutes().toInt() + ) + } + // Otherwise we just use the date + else -> MediumDateFormatter.format(published) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2, + modifier = Modifier + .padding(horizontal = 8.dp) + ) + } + if (summary != null) { + val summaryInParagraphs = summary.split("\n+".toRegex()).orEmpty() + items(summaryInParagraphs) { + HtmlTextContainer(text = summary) { + Text( + text = it, + style = MaterialTheme.typography.body2, + color = LocalContentColor.current, + modifier = Modifier.listTextPadding() + ) + } + } + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt new file mode 100644 index 0000000000..1381462913 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/episode/EpisodeViewModel.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.episode + +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.ui.Episode +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * ViewModel that handles the business logic and screen state of the Episode screen. + */ +@HiltViewModel +class EpisodeViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + private val episodeUri: String = + savedStateHandle.get<String>(Episode.EPISODE_URI).let { + Uri.decode(it) + } + + private val episodeFlow = if (episodeUri != null) { + episodeStore.episodeAndPodcastWithUri(episodeUri) + } else { + flowOf(null) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + null + ) + + val uiState: StateFlow<EpisodeScreenState> = + episodeFlow.map { + if (it != null) { + EpisodeScreenState.Loaded(it) + } else { + EpisodeScreenState.Empty + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + EpisodeScreenState.Loading, + ) + + fun onPlayEpisode(episode: PlayerEpisode) { + episodePlayer.currentEpisode = episode + episodePlayer.play() + } + fun addToQueue(episode: PlayerEpisode) { + episodePlayer.addToQueue(episode) + } +} + +@ExperimentalHorologistApi +sealed interface EpisodeScreenState { + + data object Loading : EpisodeScreenState + + data class Loaded( + val episode: EpisodeToPodcast + ) : EpisodeScreenState + + data object Empty : EpisodeScreenState +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodeViewModel.kt new file mode 100644 index 0000000000..cc78891709 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodeViewModel.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.latest_episodes + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.domain.GetLatestFollowedEpisodesUseCase +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.core.player.model.toPlayerEpisode +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class LatestEpisodeViewModel @Inject constructor( + episodesFromFavouritePodcasts: GetLatestFollowedEpisodesUseCase, + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + val uiState: StateFlow<LatestEpisodeScreenState> = + episodesFromFavouritePodcasts.invoke().map { episodeToPodcastList -> + if (episodeToPodcastList.isNotEmpty()) { + LatestEpisodeScreenState.Loaded( + episodeToPodcastList.map { + it.toPlayerEpisode() + } + ) + } else { + LatestEpisodeScreenState.Empty + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + LatestEpisodeScreenState.Loading, + ) + + fun onPlayEpisodes(episodes: List<PlayerEpisode>) { + episodePlayer.currentEpisode = episodes[0] + episodePlayer.play(episodes) + } + + fun onPlayEpisode(episode: PlayerEpisode) { + episodePlayer.currentEpisode = episode + episodePlayer.play() + } +} + +sealed interface LatestEpisodeScreenState { + + data object Loading : LatestEpisodeScreenState + + data class Loaded( + val episodeList: List<PlayerEpisode> + ) : LatestEpisodeScreenState + + data object Empty : LatestEpisodeScreenState +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt new file mode 100644 index 0000000000..732e0f68c8 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/latest_episodes/LatestEpisodesScreen.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.latest_episodes + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.ExperimentalWearMaterialApi +import androidx.wear.compose.material.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.example.jetcaster.R +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.ui.components.MediaContent +import com.example.jetcaster.ui.preview.WearPreviewEpisodes +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.AlertDialog +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.ListHeaderDefaults +import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.images.base.paintable.ImageVectorPaintable.Companion.asPaintable +import com.google.android.horologist.images.base.util.rememberVectorPainter +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@Composable fun LatestEpisodesScreen( + onPlayButtonClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + latestEpisodeViewModel: LatestEpisodeViewModel = hiltViewModel() +) { + val uiState by latestEpisodeViewModel.uiState.collectAsStateWithLifecycle() + LatestEpisodeScreen( + modifier = modifier, + uiState = uiState, + onPlayButtonClick = onPlayButtonClick, + onDismiss = onDismiss, + onPlayEpisodes = latestEpisodeViewModel::onPlayEpisodes, + onPlayEpisode = latestEpisodeViewModel::onPlayEpisode + ) +} + +@Composable +fun LatestEpisodeScreen( + uiState: LatestEpisodeScreenState, + onPlayButtonClick: () -> Unit, + onDismiss: () -> Unit, + onPlayEpisodes: (List<PlayerEpisode>) -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier, +) { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + ScreenScaffold( + scrollState = columnState, + modifier = modifier + ) { + when (uiState) { + is LatestEpisodeScreenState.Loaded -> { + LatestEpisodesScreen( + episodeList = uiState.episodeList, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode, + onPlayEpisodes = onPlayEpisodes, + modifier = modifier + ) + } + + is LatestEpisodeScreenState.Empty -> { + AlertDialog( + showDialog = true, + onDismiss = onDismiss, + message = stringResource(R.string.podcasts_no_episode_podcasts) + ) + } + + is LatestEpisodeScreenState.Loading -> { + LatestEpisodesScreenLoading( + modifier = modifier + ) + } + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun ButtonsContent( + episodes: List<PlayerEpisode>, + onPlayButtonClick: () -> Unit, + onPlayEpisodes: (List<PlayerEpisode>) -> Unit, + modifier: Modifier = Modifier +) { + Chip( + label = stringResource(id = R.string.button_play_content_description), + onClick = { + onPlayButtonClick() + onPlayEpisodes(episodes) + }, + modifier = modifier.padding(bottom = 16.dp), + icon = Icons.Outlined.PlayArrow.asPaintable(), + ) +} + +@Composable +fun LatestEpisodesScreen( + episodeList: List<PlayerEpisode>, + onPlayButtonClick: () -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit, + onPlayEpisodes: (List<PlayerEpisode>) -> Unit, + modifier: Modifier = Modifier +) { + EntityScreen( + modifier = modifier, + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(id = R.string.latest_episodes),) + } + }, + content = { + items(count = episodeList.size) { index -> + MediaContent( + episode = episodeList[index], + episodeArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onItemClick = { + onPlayButtonClick() + onPlayEpisode(episodeList[index]) + } + ) + } + }, + buttonsContent = { + ButtonsContent( + episodes = episodeList, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisodes = onPlayEpisodes + ) + }, + ) +} + +@OptIn(ExperimentalWearMaterialApi::class) +@Composable +fun LatestEpisodesScreenLoading( + modifier: Modifier = Modifier +) { + EntityScreen( + modifier = modifier, + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(id = R.string.latest_episodes),) + } + }, + content = { + items(count = 2) { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + }, + buttonsContent = { + ButtonsContent( + episodes = emptyList(), + onPlayButtonClick = { }, + onPlayEpisodes = { }, + ) + }, + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun LatestEpisodeScreenLoadedPreview( + @PreviewParameter(WearPreviewEpisodes::class) + episode: PlayerEpisode +) { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + LatestEpisodesScreen( + episodeList = listOf(episode), + onPlayButtonClick = { }, + onPlayEpisode = { }, + onPlayEpisodes = { } + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun LatestEpisodeScreenLoadingPreview() { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + LatestEpisodesScreenLoading() +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt new file mode 100644 index 0000000000..3c80928328 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryScreen.kt @@ -0,0 +1,242 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.library + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.ExperimentalWearMaterialApi +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import com.example.jetcaster.R +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.listTextPadding +import com.google.android.horologist.compose.layout.ScalingLazyColumnState +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.ListHeaderDefaults +import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.images.base.paintable.DrawableResPaintable +import com.google.android.horologist.images.base.util.rememberVectorPainter +import com.google.android.horologist.images.coil.CoilPaintable +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@Composable +fun LibraryScreen( + onLatestEpisodeClick: () -> Unit, + onYourPodcastClick: () -> Unit, + onUpNextClick: () -> Unit, + modifier: Modifier = Modifier, + libraryScreenViewModel: LibraryViewModel = hiltViewModel() +) { + val uiState by libraryScreenViewModel.uiState.collectAsState() + + val columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip, + ), + ) + + when (val s = uiState) { + is LibraryScreenUiState.Loading -> + LoadingScreen( + modifier = modifier + ) + is LibraryScreenUiState.NoSubscribedPodcast -> + NoSubscribedPodcastScreen( + columnState = columnState, + modifier = modifier, + topPodcasts = s.topPodcasts, + onTogglePodcastFollowed = libraryScreenViewModel::onTogglePodcastFollowed + ) + + is LibraryScreenUiState.Ready -> + LibraryScreen( + columnState = columnState, + modifier = modifier, + onLatestEpisodeClick = onLatestEpisodeClick, + onYourPodcastClick = onYourPodcastClick, + onUpNextClick = onUpNextClick, + queue = s.queue + ) + } +} + +@OptIn(ExperimentalWearMaterialApi::class) +@Composable +fun LoadingScreen( + modifier: Modifier, +) { + EntityScreen( + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(R.string.loading)) + } + }, + modifier = modifier, + content = { + items(count = 2) { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } + ) +} + +@OptIn(ExperimentalWearMaterialApi::class) +@Composable +fun NoSubscribedPodcastScreen( + columnState: ScalingLazyColumnState, + modifier: Modifier, + topPodcasts: List<PodcastInfo>, + onTogglePodcastFollowed: (uri: String) -> Unit +) { + ScreenScaffold(scrollState = columnState, modifier = modifier) { + ScalingLazyColumn(columnState = columnState) { + item { + ResponsiveListHeader( + modifier = modifier.listTextPadding(), + contentColor = MaterialTheme.colors.onSurface + ) { + Text(stringResource(R.string.entity_no_featured_podcasts)) + } + } + if (topPodcasts.isNotEmpty()) { + items(topPodcasts.take(3)) { podcast -> + PodcastContent( + podcast = podcast, + downloadItemArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onClick = { + onTogglePodcastFollowed(podcast.uri) + }, + ) + } + } else { + item { + PlaceholderChip( + contentDescription = "", + colors = ChipDefaults.secondaryChipColors() + ) + } + } + } + } +} + +@Composable +private fun PodcastContent( + podcast: PodcastInfo, + downloadItemArtworkPlaceholder: Painter?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val mediaTitle = podcast.title + + Chip( + label = mediaTitle, + onClick = onClick, + modifier = modifier, + icon = CoilPaintable(podcast.imageUrl, downloadItemArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) +} + +@Composable +fun LibraryScreen( + columnState: ScalingLazyColumnState, + modifier: Modifier, + onLatestEpisodeClick: () -> Unit, + onYourPodcastClick: () -> Unit, + onUpNextClick: () -> Unit, + queue: List<PlayerEpisode> +) { + ScreenScaffold(scrollState = columnState, modifier = modifier) { + ScalingLazyColumn(columnState = columnState) { + item { + ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + Text(stringResource(R.string.home_library)) + } + } + item { + Chip( + label = stringResource(R.string.latest_episodes), + onClick = onLatestEpisodeClick, + icon = DrawableResPaintable(R.drawable.new_releases), + colors = ChipDefaults.secondaryChipColors() + ) + } + item { + Chip( + label = stringResource(R.string.podcasts), + onClick = onYourPodcastClick, + icon = DrawableResPaintable(R.drawable.podcast), + colors = ChipDefaults.secondaryChipColors() + ) + } + item { + ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + Text(stringResource(R.string.queue)) + } + } + item { + if (queue.isEmpty()) { + QueueEmpty() + } else { + Chip( + label = stringResource(R.string.up_next), + onClick = onUpNextClick, + icon = DrawableResPaintable(R.drawable.up_next), + colors = ChipDefaults.secondaryChipColors() + ) + } + } + } + } +} + +@Composable +private fun QueueEmpty() { + Text( + text = stringResource(id = R.string.add_episode_to_queue), + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryViewModel.kt new file mode 100644 index 0000000000..27ae2e85e1 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LibraryViewModel.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.library + +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.domain.PodcastCategoryFilterUseCase +import com.example.jetcaster.core.model.CategoryTechnology +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 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 LibraryViewModel @Inject constructor( + private val podcastsRepository: PodcastsRepository, + private val episodeStore: EpisodeStore, + private val podcastStore: PodcastStore, + private val episodePlayer: EpisodePlayer, + private val categoryStore: CategoryStore, + private val podcastCategoryFilterUseCase: PodcastCategoryFilterUseCase +) : ViewModel() { + + private val defaultCategory = categoryStore.getCategory(CategoryTechnology) + private val topPodcastsFlow = defaultCategory.flatMapLatest { + podcastCategoryFilterUseCase(it?.asExternalModel()) + } + + private val followingPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode() + + private val queue = episodePlayer.playerState.map { + it.queue + } + + @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 -> + (list.map { it.toPlayerEpisode() }) + } + + val uiState = + combine( + topPodcastsFlow, + followingPodcastListFlow, + latestEpisodeListFlow, + queue + ) { topPodcasts, podcastList, episodeList, queue -> + if (podcastList.isEmpty()) { + LibraryScreenUiState.NoSubscribedPodcast(topPodcasts.topPodcasts) + } else { + LibraryScreenUiState.Ready(podcastList, episodeList, queue) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + LibraryScreenUiState.Loading + ) + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } + + fun playEpisode(playerEpisode: PlayerEpisode) { + episodePlayer.play(playerEpisode) + } + + fun onTogglePodcastFollowed(podcastUri: String) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcastUri) + } + } +} + +sealed interface LibraryScreenUiState { + data object Loading : LibraryScreenUiState + data class NoSubscribedPodcast( + val topPodcasts: List<PodcastInfo> + ) : LibraryScreenUiState + data class Ready( + val subscribedPodcastList: List<PodcastWithExtraInfo>, + val latestEpisodeList: List<PlayerEpisode>, + val queue: List<PlayerEpisode> + ) : LibraryScreenUiState +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt new file mode 100644 index 0000000000..7e6f957db2 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -0,0 +1,185 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.player + +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.ExperimentalWearFoundationApi +import androidx.wear.compose.foundation.rememberActiveFocusRequester +import androidx.wear.compose.foundation.rotary.rotaryScrollable +import androidx.wear.compose.material.ExperimentalWearMaterialApi +import androidx.wear.compose.material.MaterialTheme +import com.example.jetcaster.R +import com.example.jetcaster.ui.components.SettingsButtons +import com.google.android.horologist.audio.ui.VolumeUiState +import com.google.android.horologist.audio.ui.VolumeViewModel +import com.google.android.horologist.audio.ui.volumeRotaryBehavior +import com.google.android.horologist.images.coil.CoilPaintable +import com.google.android.horologist.media.ui.components.PodcastControlButtons +import com.google.android.horologist.media.ui.components.background.ArtworkColorBackground +import com.google.android.horologist.media.ui.components.controls.SeekButtonIncrement +import com.google.android.horologist.media.ui.components.display.LoadingMediaDisplay +import com.google.android.horologist.media.ui.components.display.TextMediaDisplay +import com.google.android.horologist.media.ui.screens.player.PlayerScreen + +@Composable +fun PlayerScreen( + volumeViewModel: VolumeViewModel, + onVolumeClick: () -> Unit, + modifier: Modifier = Modifier, + playerScreenViewModel: PlayerViewModel = hiltViewModel(), +) { + val volumeUiState by volumeViewModel.volumeUiState.collectAsStateWithLifecycle() + + PlayerScreen( + playerScreenViewModel = playerScreenViewModel, + volumeUiState = volumeUiState, + onVolumeClick = onVolumeClick, + onUpdateVolume = { newVolume -> volumeViewModel.setVolume(newVolume) }, + modifier = modifier + ) +} + +@OptIn(ExperimentalWearFoundationApi::class, ExperimentalWearMaterialApi::class) +@Composable +private fun PlayerScreen( + playerScreenViewModel: PlayerViewModel, + volumeUiState: VolumeUiState, + onVolumeClick: () -> Unit, + onUpdateVolume: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by playerScreenViewModel.uiState.collectAsStateWithLifecycle() + + when (val state = uiState) { + PlayerScreenUiState.Loading -> LoadingMediaDisplay(modifier) + PlayerScreenUiState.Empty -> { + PlayerScreen( + mediaDisplay = { + TextMediaDisplay( + title = stringResource(R.string.nothing_playing), + subtitle = "" + ) + }, + controlButtons = { + PodcastControlButtons( + onPlayButtonClick = playerScreenViewModel::onPlay, + onPauseButtonClick = playerScreenViewModel::onPause, + playPauseButtonEnabled = false, + playing = false, + onSeekBackButtonClick = playerScreenViewModel::onRewindBy, + seekBackButtonEnabled = false, + onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, + seekForwardButtonEnabled = false + ) + }, + buttons = { + SettingsButtons( + volumeUiState = volumeUiState, + onVolumeClick = onVolumeClick, + playerUiState = PlayerUiState(), + onPlaybackSpeedChange = playerScreenViewModel::onPlaybackSpeedChange, + enabled = false, + ) + }, + ) + } + + is PlayerScreenUiState.Ready -> { + // When screen is ready, episode is always not null, however EpisodePlayerState may + // return a null episode + val episode = state.playerState.episodePlayerState.currentEpisode + + PlayerScreen( + mediaDisplay = { + if (episode != null && episode.title.isNotEmpty()) { + TextMediaDisplay( + title = episode.podcastName, + subtitle = episode.title + ) + } else { + TextMediaDisplay( + title = stringResource(R.string.nothing_playing), + subtitle = "" + ) + } + }, + + controlButtons = { + PodcastControlButtons( + onPlayButtonClick = playerScreenViewModel::onPlay, + onPauseButtonClick = playerScreenViewModel::onPause, + playPauseButtonEnabled = true, + playing = state.playerState.episodePlayerState.isPlaying, + onSeekBackButtonClick = playerScreenViewModel::onRewindBy, + seekBackButtonEnabled = true, + onSeekForwardButtonClick = playerScreenViewModel::onAdvanceBy, + seekForwardButtonEnabled = true, + seekBackButtonIncrement = SeekButtonIncrement.Ten, + seekForwardButtonIncrement = SeekButtonIncrement.Ten, + trackPositionUiModel = state.playerState.trackPositionUiModel + ) + }, + buttons = { + SettingsButtons( + volumeUiState = volumeUiState, + onVolumeClick = onVolumeClick, + playerUiState = state.playerState, + onPlaybackSpeedChange = playerScreenViewModel::onPlaybackSpeedChange, + enabled = true, + ) + }, + modifier = modifier + .rotaryScrollable( + volumeRotaryBehavior( + volumeUiStateProvider = { volumeUiState }, + onRotaryVolumeInput = { onUpdateVolume }, + ), + focusRequester = rememberActiveFocusRequester(), + ), + background = { + ArtworkColorBackground( + paintable = episode?.let { CoilPaintable(episode.podcastImageUrl) }, + defaultColor = MaterialTheme.colors.primary, + modifier = Modifier.fillMaxSize(), + ) + } + ) + } + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt new file mode 100644 index 0000000000..c640b92021 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.player + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.EpisodePlayerState +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.media.ui.state.model.TrackPositionUiModel +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Duration +import javax.inject.Inject +import kotlin.time.toKotlinDuration +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@OptIn(ExperimentalHorologistApi::class) +data class PlayerUiState( + val episodePlayerState: EpisodePlayerState = EpisodePlayerState(), + var trackPositionUiModel: TrackPositionUiModel = TrackPositionUiModel.Actual.ZERO +) + +/** + * ViewModel that handles the business logic and screen state of the Player screen + */ +@HiltViewModel +@OptIn(ExperimentalHorologistApi::class) +class PlayerViewModel @Inject constructor( + private val episodePlayer: EpisodePlayer, +) : ViewModel() { + + val uiState = episodePlayer.playerState.map { + if (it.currentEpisode == null && it.queue.isEmpty()) { + PlayerScreenUiState.Empty + } else { + PlayerScreenUiState.Ready(PlayerUiState(it, buildPositionModel(it))) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + PlayerScreenUiState.Loading + ) + + private fun buildPositionModel(it: EpisodePlayerState) = + if (it.currentEpisode != null) { + TrackPositionUiModel.Actual( + percent = it.timeElapsed.toMillis().toFloat() / + ( + it.currentEpisode?.duration?.toMillis() + ?.toFloat() ?: 0f + ), + duration = it.currentEpisode?.duration?.toKotlinDuration() + ?: Duration.ZERO.toKotlinDuration(), + position = it.timeElapsed.toKotlinDuration() + ) + } else { + TrackPositionUiModel.Actual.ZERO + } + + fun onPlay() { + episodePlayer.play() + } + + fun onPause() { + episodePlayer.pause() + } + + fun onAdvanceBy() { + episodePlayer.advanceBy(Duration.ofSeconds(10)) + } + + fun onRewindBy() { + episodePlayer.rewindBy(Duration.ofSeconds(10)) + } + + fun onPlaybackSpeedChange() { + if (episodePlayer.playerState.value.playbackSpeed == Duration.ofSeconds(2)) { + episodePlayer.decreaseSpeed(speed = Duration.ofMillis(1000)) + } else { + episodePlayer.increaseSpeed() + } + } +} + +sealed class PlayerScreenUiState { + data object Loading : PlayerScreenUiState() + data class Ready( + val playerState: PlayerUiState + ) : PlayerScreenUiState() + + data object Empty : PlayerScreenUiState() +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt new file mode 100644 index 0000000000..cd0644d5e8 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsScreen.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.podcast + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.ExperimentalWearMaterialApi +import androidx.wear.compose.material.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.example.jetcaster.R +import com.example.jetcaster.core.domain.testing.PreviewPodcastEpisodes +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.ui.components.MediaContent +import com.example.jetcaster.ui.preview.WearPreviewEpisodes +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.AlertDialog +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.ListHeaderDefaults +import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.images.base.paintable.ImageVectorPaintable.Companion.asPaintable +import com.google.android.horologist.images.base.util.rememberVectorPainter +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@Composable fun PodcastDetailsScreen( + onPlayButtonClick: () -> Unit, + onEpisodeItemClick: (PlayerEpisode) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + podcastDetailsViewModel: PodcastDetailsViewModel = hiltViewModel() +) { + val uiState by podcastDetailsViewModel.uiState.collectAsStateWithLifecycle() + + PodcastDetailsScreen( + uiState = uiState, + onEpisodeItemClick = onEpisodeItemClick, + onPlayEpisode = podcastDetailsViewModel::onPlayEpisodes, + onDismiss = onDismiss, + onPlayButtonClick = onPlayButtonClick, + modifier = modifier, + ) +} + +@OptIn(ExperimentalWearMaterialApi::class) +@Composable +fun PodcastDetailsScreen( + uiState: PodcastDetailsScreenState, + onPlayButtonClick: () -> Unit, + modifier: Modifier = Modifier, + onEpisodeItemClick: (PlayerEpisode) -> Unit, + onPlayEpisode: (List<PlayerEpisode>) -> Unit, + onDismiss: () -> Unit +) { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + ScreenScaffold( + scrollState = columnState, + modifier = modifier + ) { + when (uiState) { + is PodcastDetailsScreenState.Loaded -> { + EntityScreen( + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = uiState.podcast.title) + } + }, + buttonsContent = { + ButtonsContent( + episodes = uiState.episodeList, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode + ) + }, + content = { + items(uiState.episodeList) { episode -> + MediaContent( + episode = episode, + episodeArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onEpisodeItemClick + ) + } + } + ) + } + + PodcastDetailsScreenState.Empty -> { + AlertDialog( + showDialog = true, + onDismiss = { onDismiss }, + message = stringResource(R.string.podcasts_no_episode_podcasts) + ) + } + PodcastDetailsScreenState.Loading -> { + EntityScreen( + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(id = R.string.loading)) + } + }, + buttonsContent = { + ButtonsContent( + episodes = emptyList(), + onPlayButtonClick = { }, + onPlayEpisode = { } + ) + }, + content = { + items(count = 2) { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } + ) + } + } + } +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun ButtonsContent( + episodes: List<PlayerEpisode>, + onPlayButtonClick: () -> Unit, + onPlayEpisode: (List<PlayerEpisode>) -> Unit, +) { + + Chip( + label = stringResource(id = R.string.button_play_content_description), + onClick = { + onPlayButtonClick() + onPlayEpisode(episodes) + }, + modifier = Modifier.padding(bottom = 16.dp), + icon = Icons.Outlined.PlayArrow.asPaintable(), + ) +} + +@ExperimentalHorologistApi +sealed class PodcastDetailsScreenState { + + data object Loading : PodcastDetailsScreenState() + + data class Loaded( + val episodeList: List<PlayerEpisode>, + val podcast: PodcastInfo, + ) : PodcastDetailsScreenState() + + data object Empty : PodcastDetailsScreenState() +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastDetailsScreenLoadedPreview( + @PreviewParameter(WearPreviewEpisodes::class) + episode: PlayerEpisode +) { + PodcastDetailsScreen( + uiState = PodcastDetailsScreenState.Loaded( + episodeList = listOf(episode), + podcast = PreviewPodcastEpisodes.first().podcast + ), + onPlayButtonClick = { }, + onEpisodeItemClick = {}, + onPlayEpisode = {}, + onDismiss = {} + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastDetailsScreenLoadingPreview( + @PreviewParameter(WearPreviewEpisodes::class) + episode: PlayerEpisode +) { + PodcastDetailsScreen( + uiState = PodcastDetailsScreenState.Loading, + onPlayButtonClick = { }, + onEpisodeItemClick = {}, + onPlayEpisode = {}, + onDismiss = {} + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt new file mode 100644 index 0000000000..b8c778044a --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcast/PodcastDetailsViewModel.kt @@ -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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.podcast + +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.net.Uri +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.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.ui.PodcastDetails +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +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 + +/** + * ViewModel that handles the business logic and screen state of the Podcast details screen. + */ +@HiltViewModel +class PodcastDetailsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + episodeStore: EpisodeStore, + private val episodePlayer: EpisodePlayer, + podcastStore: PodcastStore +) : ViewModel() { + + private val podcastUri: String = + savedStateHandle.get<String>(PodcastDetails.PODCAST_URI).let { + Uri.decode(it) + } + + private val podcastFlow = if (podcastUri != null) { + podcastStore.podcastWithExtraInfo(podcastUri) + } else { + flowOf(null) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + null + ) + + private val episodeListFlow = podcastFlow.flatMapLatest { + if (it != null) { + episodeStore.episodesInPodcast(it.podcast.uri) + } else { + flowOf(emptyList()) + } + }.map { list -> + list.map { it.toPlayerEpisode() } + } + + val uiState: StateFlow<PodcastDetailsScreenState> = + combine( + podcastFlow, + episodeListFlow + ) { podcast, episodes -> + if (podcast != null) { + PodcastDetailsScreenState.Loaded( + podcast = podcast.podcast.asExternalModel() + .copy(isSubscribed = podcast.isFollowed), + episodeList = episodes, + ) + } else { + PodcastDetailsScreenState.Empty + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + PodcastDetailsScreenState.Loading, + ) + + fun onPlayEpisodes(episodes: List<PlayerEpisode>) { + episodePlayer.currentEpisode = episodes[0] + episodePlayer.play(episodes) + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt new file mode 100644 index 0000000000..55d941d6a3 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsScreen.kt @@ -0,0 +1,230 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.podcasts + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.ExperimentalWearMaterialApi +import androidx.wear.compose.material.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.example.jetcaster.R +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.ui.preview.WearPreviewPodcasts +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.AlertDialog +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.ListHeaderDefaults +import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.images.base.util.rememberVectorPainter +import com.google.android.horologist.images.coil.CoilPaintable +import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@Composable +fun PodcastsScreen( + podcastsViewModel: PodcastsViewModel = hiltViewModel(), + onPodcastsItemClick: (PodcastInfo) -> Unit, + onDismiss: () -> Unit, +) { + val uiState by podcastsViewModel.uiState.collectAsStateWithLifecycle() + + val modifiedState = when (uiState) { + is PodcastsScreenState.Loaded -> { + val modifiedPodcast = (uiState as PodcastsScreenState.Loaded).podcastList.map { + it.takeIf { it.title.isNotEmpty() } + ?: it.copy(title = stringResource(id = R.string.no_title)) + } + + PodcastsScreenState.Loaded(modifiedPodcast) + } + + PodcastsScreenState.Empty, + PodcastsScreenState.Loading, + -> uiState + } + + PodcastsScreen( + podcastsScreenState = modifiedState, + onPodcastsItemClick = onPodcastsItemClick, + onDismiss = onDismiss + ) +} + +@ExperimentalHorologistApi +@Composable +fun PodcastsScreen( + podcastsScreenState: PodcastsScreenState, + onPodcastsItemClick: (PodcastInfo) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + + val columnState = rememberResponsiveColumnState() + ScreenScaffold( + scrollState = columnState, + modifier = modifier + ) { + when (podcastsScreenState) { + is PodcastsScreenState.Loaded -> PodcastScreenLoaded( + podcastList = podcastsScreenState.podcastList, + onPodcastsItemClick = onPodcastsItemClick + ) + PodcastsScreenState.Empty -> + PodcastScreenEmpty(onDismiss) + PodcastsScreenState.Loading -> + PodcastScreenLoading() + } + } +} + +@Composable +fun PodcastScreenLoaded( + podcastList: List<PodcastInfo>, + onPodcastsItemClick: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier +) { + EntityScreen( + modifier = modifier, + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(id = R.string.podcasts)) + } + }, + content = { + items(count = podcastList.size) { + index -> + MediaContent( + podcast = podcastList[index], + downloadItemArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onPodcastsItemClick = onPodcastsItemClick + + ) + } + } + ) +} + +@Composable +fun PodcastScreenEmpty( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + AlertDialog( + showDialog = true, + message = stringResource(R.string.podcasts_no_podcasts), + onDismiss = onDismiss, + modifier = modifier + ) +} + +@OptIn(ExperimentalWearMaterialApi::class) +@Composable +fun PodcastScreenLoading( + modifier: Modifier = Modifier +) { + EntityScreen( + modifier = modifier, + headerContent = { + DefaultEntityScreenHeader( + title = stringResource(R.string.podcasts) + ) + }, + content = { + items(count = 2) { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastScreenLoadedPreview( + @PreviewParameter(WearPreviewPodcasts::class) podcasts: PodcastInfo +) { + val columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + PodcastScreenLoaded( + podcastList = listOf(podcasts), + onPodcastsItemClick = {} + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastScreenLoadingPreview() { + val columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + PodcastScreenLoading() +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun PodcastScreenEmptyPreview() { + PodcastScreenEmpty(onDismiss = {}) +} + +@Composable +fun MediaContent( + podcast: PodcastInfo, + downloadItemArtworkPlaceholder: Painter?, + onPodcastsItemClick: (PodcastInfo) -> Unit +) { + val mediaTitle = podcast.title + + val secondaryLabel = podcast.author + + Chip( + label = mediaTitle, + onClick = { onPodcastsItemClick(podcast) }, + secondaryLabel = secondaryLabel, + icon = CoilPaintable(podcast.imageUrl, downloadItemArtworkPlaceholder), + largeIcon = true, + colors = ChipDefaults.secondaryChipColors(), + ) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsViewModel.kt new file mode 100644 index 0000000000..65d7f0666b --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/podcasts/PodcastsViewModel.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.ui.podcasts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.core.model.asExternalModel +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class PodcastsViewModel @Inject constructor( + podcastStore: PodcastStore, +) : ViewModel() { + + val uiState: StateFlow<PodcastsScreenState> = + podcastStore.followedPodcastsSortedByLastEpisode(limit = 10).map { + if (it.isNotEmpty()) { + PodcastsScreenState.Loaded(it.map(PodcastMapper::map)) + } else { + PodcastsScreenState.Empty + } + }.catch { + emit(PodcastsScreenState.Empty) + }.stateIn( + viewModelScope, + started = SharingStarted.Eagerly, + initialValue = PodcastsScreenState.Loading, + ) +} + +object PodcastMapper { + + /** + * Maps from [Podcast]. + */ + fun map( + podcastWithExtraInfo: PodcastWithExtraInfo, + ): PodcastInfo = + podcastWithExtraInfo.asExternalModel() +} + +@ExperimentalHorologistApi +sealed interface PodcastsScreenState { + + data object Loading : PodcastsScreenState + + data class Loaded( + val podcastList: List<PodcastInfo>, + ) : PodcastsScreenState + + data object Empty : PodcastsScreenState +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/preview/WearPreviewEpisodes.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/preview/WearPreviewEpisodes.kt new file mode 100644 index 0000000000..8395f22c7d --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/preview/WearPreviewEpisodes.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.example.jetcaster.core.domain.testing.PreviewPlayerEpisodes +import com.example.jetcaster.core.player.model.PlayerEpisode + +public class WearPreviewEpisodes : PreviewParameterProvider<PlayerEpisode> { + public override val values: Sequence<PlayerEpisode> + get() = PreviewPlayerEpisodes.asSequence() +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/preview/WearPreviewPodcasts.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/preview/WearPreviewPodcasts.kt new file mode 100644 index 0000000000..ab4233f51d --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/preview/WearPreviewPodcasts.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.preview +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.example.jetcaster.core.domain.testing.PreviewPodcasts +import com.example.jetcaster.core.model.PodcastInfo + +public class WearPreviewPodcasts : PreviewParameterProvider<PodcastInfo> { + public override val values: Sequence<PodcastInfo> + get() = PreviewPodcasts.asSequence() +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt new file mode 100644 index 0000000000..f06d09c88e --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueScreen.kt @@ -0,0 +1,280 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.queue + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.ExperimentalWearMaterialApi +import androidx.wear.compose.material.Text +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.example.jetcaster.R +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.example.jetcaster.ui.components.MediaContent +import com.example.jetcaster.ui.preview.WearPreviewEpisodes +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.composables.PlaceholderChip +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.padding +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.AlertDialog +import com.google.android.horologist.compose.material.Button +import com.google.android.horologist.compose.material.ListHeaderDefaults +import com.google.android.horologist.compose.material.ResponsiveListHeader +import com.google.android.horologist.images.base.util.rememberVectorPainter +import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader +import com.google.android.horologist.media.ui.screens.entity.EntityScreen + +@Composable fun QueueScreen( + onPlayButtonClick: () -> Unit, + onEpisodeItemClick: (PlayerEpisode) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + queueViewModel: QueueViewModel = hiltViewModel() +) { + val uiState by queueViewModel.uiState.collectAsStateWithLifecycle() + + QueueScreen( + uiState = uiState, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisodes = queueViewModel::onPlayEpisodes, + modifier = modifier, + onEpisodeItemClick = onEpisodeItemClick, + onDeleteQueueEpisodes = queueViewModel::onDeleteQueueEpisodes, + onDismiss = onDismiss + ) +} + +@Composable +fun QueueScreen( + uiState: QueueScreenState, + onPlayButtonClick: () -> Unit, + onPlayEpisodes: (List<PlayerEpisode>) -> Unit, + modifier: Modifier = Modifier, + onEpisodeItemClick: (PlayerEpisode) -> Unit, + onDeleteQueueEpisodes: () -> Unit, + onDismiss: () -> Unit +) { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + ScreenScaffold( + scrollState = columnState, + modifier = modifier + ) { + when (uiState) { + is QueueScreenState.Loaded -> QueueScreenLoaded( + episodeList = uiState.episodeList, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisodes = onPlayEpisodes, + onDeleteQueueEpisodes = onDeleteQueueEpisodes, + onEpisodeItemClick = onEpisodeItemClick + ) + QueueScreenState.Loading -> QueueScreenLoading() + QueueScreenState.Empty -> QueueScreenEmpty(onDismiss) + } + } +} + +@Composable +fun QueueScreenLoaded( + episodeList: List<PlayerEpisode>, + onPlayButtonClick: () -> Unit, + onPlayEpisodes: (List<PlayerEpisode>) -> Unit, + onDeleteQueueEpisodes: () -> Unit, + onEpisodeItemClick: (PlayerEpisode) -> Unit, + modifier: Modifier = Modifier +) { + EntityScreen( + modifier = modifier, + headerContent = { + ResponsiveListHeader( + contentPadding = ListHeaderDefaults.firstItemPadding() + ) { + Text(text = stringResource(R.string.queue)) + } + }, + buttonsContent = { + ButtonsContent( + episodes = episodeList, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisodes = onPlayEpisodes, + onDeleteQueueEpisodes = onDeleteQueueEpisodes + ) + }, + content = { + items(episodeList) { episode -> + MediaContent( + episode = episode, + episodeArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onItemClick = onEpisodeItemClick + ) + } + } + ) +} + +@OptIn(ExperimentalWearMaterialApi::class) +@Composable +fun QueueScreenLoading( + modifier: Modifier = Modifier +) { + EntityScreen( + modifier = modifier, + headerContent = { + DefaultEntityScreenHeader( + title = stringResource(R.string.queue) + ) + }, + buttonsContent = { + ButtonsContent( + episodes = emptyList(), + onPlayButtonClick = {}, + onPlayEpisodes = {}, + onDeleteQueueEpisodes = { }, + enabled = false + ) + }, + content = { + items(count = 2) { + PlaceholderChip(colors = ChipDefaults.secondaryChipColors()) + } + } + ) +} + +@Composable +fun QueueScreenEmpty( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + AlertDialog( + showDialog = true, + onDismiss = onDismiss, + title = stringResource(R.string.display_nothing_in_queue), + message = stringResource(R.string.no_episodes_from_queue), + modifier = modifier + ) +} + +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun ButtonsContent( + episodes: List<PlayerEpisode>, + onPlayButtonClick: () -> Unit, + onPlayEpisodes: (List<PlayerEpisode>) -> Unit, + onDeleteQueueEpisodes: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier +) { + + Row( + modifier = modifier + .padding(bottom = 16.dp) + .height(52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + ) { + Button( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = stringResource(id = R.string.button_play_content_description), + onClick = { + onPlayButtonClick() + onPlayEpisodes(episodes) + }, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + enabled = enabled + ) + Button( + imageVector = Icons.Outlined.Delete, + contentDescription = + stringResource(id = R.string.button_delete_queue_content_description), + onClick = onDeleteQueueEpisodes, + modifier = Modifier + .weight(weight = 0.3F, fill = false), + enabled = enabled + ) + } +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun QueueScreenLoadedPreview( + @PreviewParameter(WearPreviewEpisodes::class) + episode: PlayerEpisode +) { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + QueueScreenLoaded( + episodeList = listOf(episode), + onPlayButtonClick = { }, + onPlayEpisodes = { }, + onDeleteQueueEpisodes = { }, + onEpisodeItemClick = { } + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun QueueScreenLoadingPreview() { + val columnState = rememberResponsiveColumnState( + contentPadding = padding( + first = ScalingLazyColumnDefaults.ItemType.Text, + last = ScalingLazyColumnDefaults.ItemType.Chip + ) + ) + QueueScreenLoading() +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun QueueScreenEmptyPreview() { + QueueScreenEmpty(onDismiss = {}) +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueViewModel.kt new file mode 100644 index 0000000000..bdd38f694d --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/queue/QueueViewModel.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.ui.queue + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.model.PlayerEpisode +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * ViewModel that handles the business logic and screen state of the Queue screen. + */ +@HiltViewModel +class QueueViewModel @Inject constructor( + private val episodePlayer: EpisodePlayer, + +) : ViewModel() { + + val uiState: StateFlow<QueueScreenState> = episodePlayer.playerState.map { + if (it.queue.isNotEmpty()) { + QueueScreenState.Loaded(it.queue) + } else { + QueueScreenState.Empty + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + QueueScreenState.Loading, + ) + + fun onPlayEpisode(episode: PlayerEpisode) { + episodePlayer.currentEpisode = episode + episodePlayer.play() + } + + fun onPlayEpisodes(episodes: List<PlayerEpisode>) { + episodePlayer.currentEpisode = episodes[0] + episodePlayer.play(episodes) + } + + fun onDeleteQueueEpisodes() { + episodePlayer.removeAllFromQueue() + } +} + +@ExperimentalHorologistApi +sealed interface QueueScreenState { + + data object Loading : QueueScreenState + + data class Loaded( + val episodeList: List<PlayerEpisode> + ) : QueueScreenState + + data object Empty : QueueScreenState +} diff --git a/Jetcaster/wear/src/main/res/drawable/ic_launcher_foreground.xml b/Jetcaster/wear/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..930f227590 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#F27405" + android:pathData="M48.49,64.6c-1.52,0 -2.76,-0.68 -2.76,-1.5V36.2c0,-0.82 1.24,-1.5 2.76,-1.5 1.52,0 2.75,0.68 2.75,1.5V63.1c0,0.82 -1.23,1.5 -2.75,1.5zM37.47,55.63c-1.52,0 -2.76,-0.68 -2.76,-1.5v-8.97c0,-0.82 1.24,-1.5 2.76,-1.5 1.51,0 2.75,0.68 2.75,1.5v8.97c0,0.82 -1.24,1.5 -2.75,1.5zM59.51,58.62c-1.52,0 -2.75,-0.68 -2.75,-1.5V42.17c0,-0.82 1.23,-1.5 2.75,-1.5s2.76,0.68 2.76,1.5v14.95c0,0.82 -1.24,1.5 -2.76,1.5zM70.53,54.13c-1.51,0 -2.75,-0.67 -2.75,-1.5v-5.97c0,-0.83 1.24,-1.5 2.75,-1.5 1.52,0 2.76,0.67 2.76,1.5v5.98c0,0.82 -1.24,1.5 -2.76,1.5z" /> + <path + android:fillColor="#FF9F0C" + android:pathData="M48.49,68.62c-1.52,0 -2.76,-0.68 -2.76,-1.5V40.21c0,-0.82 1.24,-1.5 2.76,-1.5 1.52,0 2.75,0.68 2.75,1.5v26.91c0,0.82 -1.23,1.5 -2.75,1.5zM37.47,59.65c-1.52,0 -2.76,-0.68 -2.76,-1.5v-8.97c0,-0.82 1.24,-1.5 2.76,-1.5 1.51,0 2.75,0.68 2.75,1.5v8.97c0,0.82 -1.24,1.5 -2.75,1.5zM59.51,62.64c-1.52,0 -2.75,-0.68 -2.75,-1.5V46.19c0,-0.82 1.23,-1.5 2.75,-1.5s2.76,0.68 2.76,1.5v14.95c0,0.82 -1.24,1.5 -2.76,1.5zM70.53,58.68c-1.51,0 -2.75,-0.71 -2.75,-1.58v-6.34c0,-0.87 1.24,-1.58 2.75,-1.58 1.52,0 2.76,0.71 2.76,1.58v6.34c0,0.87 -1.24,1.58 -2.76,1.58z" /> + <path + android:fillColor="#FFD083" + android:pathData="M48.49,73.27c-1.52,0 -2.76,-0.6 -2.76,-1.34V47.9c0,-0.73 1.24,-1.34 2.76,-1.34 1.51,0 2.75,0.6 2.75,1.34v24.03c0,0.74 -1.24,1.34 -2.75,1.34zM37.47,65.26c-1.52,0 -2.76,-0.6 -2.76,-1.34v-8c0,-0.74 1.24,-1.34 2.76,-1.34 1.51,0 2.75,0.6 2.75,1.33v8.01c0,0.74 -1.24,1.34 -2.75,1.34zM59.5,67.93c-1.5,0 -2.75,-0.6 -2.75,-1.34V53.24c0,-0.73 1.24,-1.33 2.76,-1.33 1.51,0 2.75,0.6 2.75,1.33v13.35c0,0.74 -1.24,1.34 -2.75,1.34zM70.52,63.92c-1.51,0 -2.75,-0.6 -2.75,-1.33v-5.34c0,-0.74 1.24,-1.34 2.75,-1.34 1.52,0 2.76,0.6 2.76,1.34v5.34c0,0.73 -1.24,1.33 -2.76,1.33z" /> +</vector> diff --git a/Jetcaster/app/src/main/res/drawable/ic_logo.xml b/Jetcaster/wear/src/main/res/drawable/ic_logo.xml similarity index 100% rename from Jetcaster/app/src/main/res/drawable/ic_logo.xml rename to Jetcaster/wear/src/main/res/drawable/ic_logo.xml diff --git a/Jetcaster/wear/src/main/res/drawable/new_releases.xml b/Jetcaster/wear/src/main/res/drawable/new_releases.xml new file mode 100644 index 0000000000..12cfa723c3 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/new_releases.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="960" android:viewportHeight="960" android:tint="?attr/colorControlNormal"> + <path android:fillColor="@android:color/white" android:pathData="M346,900L270,770L119,739L136,592L40,480L136,369L119,222L270,191L346,60L480,122L614,60L691,191L841,222L824,369L920,480L824,592L841,739L691,770L614,900L480,838L346,900ZM373,821L480,776L590,821L657,721L774,691L762,572L843,480L762,386L774,267L657,239L588,139L480,184L370,139L303,239L186,267L198,386L117,480L198,572L186,693L303,721L373,821ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480ZM437,613L664,388L619,347L437,527L342,428L296,473L437,613Z"/> +</vector> \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/podcast.xml b/Jetcaster/wear/src/main/res/drawable/podcast.xml new file mode 100644 index 0000000000..93d4a641a0 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/podcast.xml @@ -0,0 +1,3 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="960" android:viewportHeight="960" android:tint="?attr/colorControlNormal"> + <path android:fillColor="@android:color/white" android:pathData="M450,880L450,554Q428,545 414,525Q400,505 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,505 546,525Q532,545 510,554L510,880L450,880ZM204,770Q147,715 113.5,640.5Q80,566 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,566 846.5,640.5Q813,715 756,770L713,727Q763,680 791.5,617Q820,554 820,480Q820,338 721,239Q622,140 480,140Q338,140 239,239Q140,338 140,480Q140,554 168.5,617Q197,680 247,727L204,770ZM317,657Q282,624 261,578.5Q240,533 240,480Q240,380 310,310Q380,240 480,240Q580,240 650,310Q720,380 720,480Q720,533 699,578.5Q678,624 643,657L600,614Q628,589 644,555Q660,521 660,480Q660,405 607.5,352.5Q555,300 480,300Q405,300 352.5,352.5Q300,405 300,480Q300,521 316,555Q332,589 360,614L317,657Z"/> +</vector> \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/refresh.xml b/Jetcaster/wear/src/main/res/drawable/refresh.xml new file mode 100644 index 0000000000..f1c3a12a44 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/refresh.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> + <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960" android:tint="?attr/colorControlNormal"> + <path android:fillColor="@android:color/white" android:pathData="M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,160L800,160L800,440L520,440L520,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q557,720 619,676Q681,632 706,560L790,560Q762,666 676,733Q590,800 480,800Z"/> + </vector> \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/speed_15x.xml b/Jetcaster/wear/src/main/res/drawable/speed_15x.xml new file mode 100644 index 0000000000..be0c51bd94 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/speed_15x.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="960" android:viewportHeight="960" android:tint="?attr/colorControlNormal"> + <path android:fillColor="@android:color/white" android:pathData="M287,683L287,337L204,337L204,277L347,277L347,683L287,683ZM407,683L407,623L467,623L467,683L407,683ZM527,683L527,623L697,623Q697,623 697,623Q697,623 697,623L697,508Q697,508 697,508Q697,508 697,508L527,508L527,277L757,277L757,337L587,337L587,448L697,448Q721,448 739,466Q757,484 757,508L757,623Q757,647 739,665Q721,683 697,683L527,683Z"/> +</vector> \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/speed_1x.xml b/Jetcaster/wear/src/main/res/drawable/speed_1x.xml new file mode 100644 index 0000000000..4dbcfb9d7a --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/speed_1x.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="960" android:viewportHeight="960" android:tint="?attr/colorControlNormal"> + <path android:fillColor="@android:color/white" android:pathData="M242,680L242,340L160,340L160,280L302,280L302,680L242,680ZM437,680L564,467L451,280L521,280L600,410L675,280L744,280L636,467L760,680L691,680L600,524L507,680L437,680Z"/> +</vector> \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/speed_2x.xml b/Jetcaster/wear/src/main/res/drawable/speed_2x.xml new file mode 100644 index 0000000000..55db09ed1a --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/speed_2x.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="960" android:viewportHeight="960" android:tint="?attr/colorControlNormal"> + <path android:fillColor="@android:color/white" android:pathData="M205,683L205,508Q205,484 223,466Q241,448 265,448L375,448Q375,448 375,448Q375,448 375,448L375,337Q375,337 375,337Q375,337 375,337L205,337L205,277L375,277Q399,277 417,295Q435,313 435,337L435,448Q435,472 417,490Q399,508 375,508L265,508Q265,508 265,508Q265,508 265,508L265,623L435,623L435,683L205,683ZM490,680L604,474L495,280L560,280L640,423L719,280L784,280L676,474L791,680L727,680L640,526L555,680L490,680Z"/> +</vector> \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/drawable/splashscreen.xml b/Jetcaster/wear/src/main/res/drawable/splashscreen.xml new file mode 100644 index 0000000000..9973136e60 --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/splashscreen.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 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 + 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. +--> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item + android:width="@dimen/splash_screen_icon_size" + android:height="@dimen/splash_screen_icon_size" + android:drawable="@mipmap/ic_launcher" + android:gravity="center" /> +</layer-list> diff --git a/Jetcaster/wear/src/main/res/drawable/up_next.xml b/Jetcaster/wear/src/main/res/drawable/up_next.xml new file mode 100644 index 0000000000..19c71208db --- /dev/null +++ b/Jetcaster/wear/src/main/res/drawable/up_next.xml @@ -0,0 +1,3 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="960" android:viewportHeight="960" android:tint="?attr/colorControlNormal"> + <path android:fillColor="@android:color/white" android:pathData="M120,630L120,570L426,570L426,630L120,630ZM120,465L120,405L593,405L593,465L120,465ZM120,300L120,240L593,240L593,300L120,300ZM662,840L662,518L880,679L662,840Z"/> +</vector> \ No newline at end of file diff --git a/Jetcaster/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetcaster/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..5077d16b0c --- /dev/null +++ b/Jetcaster/wear/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/Jetcaster/wear/src/main/res/values-round/dimens.xml b/Jetcaster/wear/src/main/res/values-round/dimens.xml new file mode 100644 index 0000000000..9b16e76d95 --- /dev/null +++ b/Jetcaster/wear/src/main/res/values-round/dimens.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 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 + 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. +--> +<resources> + <!-- Round app icon can take all of default space --> + <dimen name="splash_screen_icon_size">48dp</dimen> +</resources> diff --git a/Jetcaster/wear/src/main/res/values-round/strings.xml b/Jetcaster/wear/src/main/res/values-round/strings.xml new file mode 100644 index 0000000000..f80865244b --- /dev/null +++ b/Jetcaster/wear/src/main/res/values-round/strings.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 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 + 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. +--> +<resources> +</resources> diff --git a/Jetcaster/wear/src/main/res/values-round/themes.xml b/Jetcaster/wear/src/main/res/values-round/themes.xml new file mode 100644 index 0000000000..c4dfa8ab7b --- /dev/null +++ b/Jetcaster/wear/src/main/res/values-round/themes.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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 + + 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. +--> +<resources xmlns:tools="http://schemas.android.com/tools"> + <style name="Theme.App" parent="@android:style/Theme.DeviceDefault" /> + + <style name="Theme.App.Starting" parent="Theme.SplashScreen.IconBackground"> + <!-- Set the splash screen background to black --> + <item name="windowSplashScreenBackground">@android:color/black</item> + <item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen</item> + <item name="postSplashScreenTheme">@style/Theme.App</item> + </style> + +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<resources> + <color name="ic_launcher_background">#121212</color> +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 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 + 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. +--> +<resources> + <!-- Round app icon can take all of default space --> + <dimen name="splash_screen_icon_size">48dp</dimen> +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 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 + 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. +--> +<resources> + <string name="app_name">Jetcaster</string> + + <string name="connection_error_title">Connection error</string> + <string name="connection_error_message">Unable to fetch podcasts feeds.\nCheck your internet connection and try again.</string> + <string name="retry_label">Retry</string> + + <string name="podcasts">Podcasts</string> + <string name="latest_episodes">Latest episodes</string> + + <string name="home_library">Your library</string> + <string name="queue">Queue</string> + <string name="up_next">Up Next</string> + <string name="home_discover">Discover</string> + <string name="settings">Settings</string> + <string name="entity_no_featured_podcasts">Your library is empty. Checkout the latest podcasts.</string> + <string name="entity_no_featured_podcasts_dialog_cancel_button_content_description">Cancel</string> + <string name="entity_no_featured_podcasts_dialog_refresh_button_content_description">Refresh</string> + + <string name="speed_button_content_description">Change Speed</string> + <string name="download_button_content_description">Download</string> + <string name="button_play_content_description">Play episodes</string> + <string name="button_delete_queue_content_description">Delete queue</string> + <string name="updated_longer">Updated a while ago</string> + <plurals name="updated_weeks_ago"> + <item quantity="one">Updated %d week ago</item> + <item quantity="other">Updated %d weeks ago</item> + </plurals> + <plurals name="updated_days_ago"> + <item quantity="one">Updated yesterday</item> + <item quantity="other">Updated %d days ago</item> + </plurals> + <string name="updated_today">Updated today</string> + + <string name="episode_date_duration">%1$s • %2$d mins</string> + + <string name="cd_search">Search</string> + <string name="cd_account">Account</string> + <string name="cd_add">Add</string> + <string name="cd_back">Back</string> + <string name="cd_more">More</string> + <string name="cd_play">Play</string> + <string name="cd_skip_previous">Skip previous</string> + <string name="cd_reply10">Reply 10 seconds</string> + <string name="cd_forward30">Forward 30 seconds</string> + <string name="cd_skip_next">Skip next</string> + <string name="cd_unfollow">Unfollow</string> + <string name="cd_follow">Follow</string> + <string name="cd_following">Following</string> + <string name="cd_not_following">Not following</string> + <string name="nothing_playing">Nothing playing</string> + + <string name="speed">Speed</string> + <string name="increase_playback_speed">Increase playback speed</string> + <string name="decrease_playback_speed">Decrease playback speed</string> + <string name="change_playback_speed_content_description">Change playback speed</string> + + <string name="podcasts_no_podcasts">No podcasts available at the moment</string> + <string name="loading">Loading</string> + <string name="podcasts_no_episode_podcasts">No episodes available at the moment</string> + <string name="no_title">No title</string> + <string name="podcasts_failed_dialog_cancel_button_content_description">Cancel</string> + + <string name="display_nothing_in_queue">No episode in the queue</string> + <string name="add_episode_to_queue">Add an episode to the queue</string> + <string name="no_episodes_from_queue">There are no episodes from the queue</string> + <string name="add_to_queue_content_description">Add to queue</string> + <string name="episode_info_not_available">Episode info not available at the moment</string> + +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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 + + 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. +--> +<resources xmlns:tools="http://schemas.android.com/tools"> + <style name="Theme.App" parent="@android:style/Theme.DeviceDefault" /> + + <style name="Theme.App.Starting" parent="Theme.SplashScreen.IconBackground"> + <!-- Set the splash screen background to black --> + <item name="windowSplashScreenBackground">@android:color/black</item> + <item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen</item> + <item name="postSplashScreenTheme">@style/Theme.App</item> + </style> + +</resources> diff --git a/Jetcaster/wear/src/test/java/com/example/jetcaster/NavigationTest.kt b/Jetcaster/wear/src/test/java/com/example/jetcaster/NavigationTest.kt new file mode 100644 index 0000000000..90b1dcb012 --- /dev/null +++ b/Jetcaster/wear/src/test/java/com/example/jetcaster/NavigationTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NavigationTest { + @get:Rule + val rule = createAndroidComposeRule(MainActivity::class.java) + + @Test + fun launchAndNavigate() { + val activity = rule.activity + + val navController = activity.navController + + rule.waitUntil { + navController.currentDestination?.route != null + } + + assertEquals("player?page={page}", navController.currentDestination?.route) + + navController.navigateToUpNext() + + assertEquals("upNext", navController.currentDestination?.route) + } +} diff --git a/Jetchat/.gitignore b/Jetchat/.gitignore index aa724b7707..834ecd9dff 100644 --- a/Jetchat/.gitignore +++ b/Jetchat/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +.kotlin/ diff --git a/Jetchat/.google/packaging.yaml b/Jetchat/.google/packaging.yaml index 84aff54914..b8e5ccd387 100644 --- a/Jetchat/.google/packaging.yaml +++ b/Jetchat/.google/packaging.yaml @@ -18,10 +18,23 @@ # End users may safely ignore this file. It has no relevance to other systems. --- status: PUBLISHED -technologies: [Android] -categories: [Compose] +technologies: [Android, JetpackCompose] +categories: + - AndroidArchitectureUILayer + - AndroidArchitectureStateProduction + - AndroidArchitectureUIEvents + - JetpackComposeArchitectureAndState + - JetpackComposeDesignSystems + - JetpackComposeAnimation + - JetpackComposeTextAndInput + - JetpackComposeTesting languages: [Kotlin] -solutions: [Mobile] +solutions: + - Mobile + - JetpackHilt + - JetpackLifecycle + - JetpackNavigation + - JetpackFragment github: android/compose-samples level: BEGINNER apiRefs: diff --git a/Jetchat/README.md b/Jetchat/README.md index 355b50986e..0433a4585d 100644 --- a/Jetchat/README.md +++ b/Jetchat/README.md @@ -4,7 +4,8 @@ Jetchat is a sample chat app built with [Jetpack Compose][compose]. -To try out these sample apps, you need to use the latest Canary version of Android Studio 4.2. +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). You can clone this repository or import the project from Android Studio following the steps [here](https://developer.android.com/jetpack/compose/setup#sample). @@ -17,10 +18,16 @@ This sample showcases: * Text Input and focus management * Multiple types of animations and transitions * Saved state across configuration changes -* Basic Material Design theming +* Material Design 3 theming and Material You dynamic color * UI tests -<img src="screenshots/jetchat.gif"/> +## Screenshots + +<img src="screenshots/screenshots.png"/> + +<img src="screenshots/widget.png" width="300"/> + +<img src="screenshots/widget_discoverability.png" width="300"/> ### Status: 🚧 In progress @@ -36,10 +43,10 @@ The [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/Prof [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel), served via [LiveData](https://developer.android.com/topic/libraries/architecture/livedata). ### Back button handling -When the Emoji selector is shown, pressing back in the app closes it, intercepting any navigation events. This feature shows a way to integrate Compose and APIs from the Android Framework like [OnBackPressedDispatcherOwner](https://developer.android.com/reference/androidx/activity/OnBackPressedDispatcher) via [Ambients](https://developer.android.com/reference/kotlin/androidx/compose/Ambient). The implementation can be found in [ConversationUiState](app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt). +When the Emoji selector is shown, pressing back in the app closes it, intercepting any navigation events. The implementation can be found in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). ### Text Input and focus management -When the Emoji panel is shown the keyboard must be hidden and vice versa. This is achieved with a combination of the [FocusRequester](https://developer.android.com/reference/kotlin/androidx/compose/ui/focus/FocusRequester) and [FocusObserver](https://developer.android.com/reference/kotlin/androidx/compose/ui/FocusObserverModifier) APIs. +When the Emoji panel is shown the keyboard must be hidden and vice versa. This is achieved with a combination of the [FocusRequester](https://developer.android.com/reference/kotlin/androidx/compose/ui/focus/FocusRequester) and [onFocusChanged](https://developer.android.com/reference/kotlin/androidx/compose/ui/focus/package-summary#(androidx.compose.ui.Modifier).onFocusChanged(kotlin.Function1)) APIs. ### Multiple types of animations and transitions This sample uses animations ranging from simple `AnimatedVisibility` in [FunctionalityNotAvailablePanel](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) to choreographed transitions found in the [FloatingActionButton](https://material.io/develop/android/components/floating-action-button) of the Profile screen and implemented in [AnimatingFabContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) @@ -47,30 +54,16 @@ This sample uses animations ranging from simple `AnimatedVisibility` in [Functio ### Edge-to-edge UI with synchronized IME transitions This sample is laid out [edge-to-edge](https://medium.com/androiddevelopers/gesture-navigation-going-edge-to-edge-812f62e4e83e), drawing its content behind the system bars for a more immersive look. -The sample also supports synchronized IME transitions when running on API 30+ devices. See the use of `Modifier.navigationBarsWithImePadding()` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). - -<img src="screenshots/ime-transition.gif" /> - -The sample uses the -[Accompanist Insets library](https://chrisbanes.github.io/accompanist/insets/) for WindowInsets support. +The sample also supports synchronized IME transitions when running on API 30+ devices. See the use of `Modifier.navigationBarsPadding().imePadding()` in [ConversationContent](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). ### Saved state across configuration changes Some composable state survives activity or process recreation, like `currentInputSelector` in [UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt). -### Basic Material Design theming -Jetchat follows the Material Design principles and uses the `MaterialTheme` ambient, with custom light and dark themes. In some cases colors it might be necessary to create additional colors, that can be specified as an overlay or combination of two, or as a specific elevation in dark mode. Jetchat uses some convenient extensions on the Material palette and can be used as follows: - -[UserInput](app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt) -```kotlin -@Composable -fun getSelectorExpandedColor(): Color { - return if (MaterialTheme.colors.isLight) { - MaterialTheme.colors.compositedOnSurface(0.04f) - } else { - MaterialTheme.colors.elevatedSurface(8.dp) - } -} -``` +### Material Design 3 theming and Material You dynamic color +Jetchat follows the [Material Design 3](https://m3.material.io) principles and uses the `MaterialTheme` composable and M3 components. On Android 12+ Jetchat supports Material You dynamic color, which extracts a custom color scheme from the device wallpaper. Jetchat uses a custom, branded color scheme as a fallback. It also implements custom typography using the Karla and Montserrat font families. + +### Nested scrolling interop +Jetchat contains an example of how to use [`rememberNestedScrollInteropConnection()`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/package-summary#rememberNestedScrollInteropConnection()) to achieve successful nested scroll interop between a View parent that implements `androidx.core.view.NestedScrollingParent3` and a Compose child. The example used here is a combination of a View parent `CoordinatorLayout` and a nested, Compose child `BoxWithConstraints` in [ProfileFragment](app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt). ### UI tests In [androidTest](app/src/androidTest/java/com/example/compose/jetchat) you'll find a suite of UI tests that showcase interesting patterns in Compose: @@ -109,4 +102,3 @@ limitations under the License. ``` [compose]: https://developer.android.com/jetpack/compose -[coil-accompanist]: https://github.com/chrisbanes/accompanist diff --git a/Jetchat/app/build.gradle b/Jetchat/app/build.gradle deleted file mode 100644 index 57d599b340..0000000000 --- a/Jetchat/app/build.gradle +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.compose.jetchat.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' -} - -android { - compileSdkVersion 30 - - defaultConfig { - applicationId "com.example.compose.jetchat" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName '1.0' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - - vectorDrawables.useSupportLibrary = true - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - buildFeatures { - compose true - viewBinding true - - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerVersion Libs.Kotlin.version - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } - - packagingOptions { - exclude "META-INF/licenses/**" - exclude "META-INF/AL2.0" - exclude "META-INF/LGPL2.1" - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - implementation Libs.AndroidX.coreKtx - implementation Libs.AndroidX.appcompat - implementation Libs.AndroidX.Lifecycle.livedata - implementation Libs.AndroidX.Navigation.fragment - implementation Libs.AndroidX.Navigation.uiKtx - implementation Libs.material - - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.materialIconsExtended - implementation Libs.AndroidX.Compose.tooling - implementation Libs.AndroidX.Compose.uiUtil - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.runtimeLivedata - implementation Libs.AndroidX.Compose.viewBinding - - implementation Libs.Accompanist.insets - - androidTestImplementation Libs.junit - androidTestImplementation Libs.AndroidX.Test.core - androidTestImplementation Libs.AndroidX.Test.espressoCore - androidTestImplementation Libs.AndroidX.Test.rules - androidTestImplementation Libs.AndroidX.Test.Ext.junit - androidTestImplementation Libs.AndroidX.Compose.uiTest -} 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 index 4cb94585a0..9e6e059b3b 100644 --- a/Jetchat/app/proguard-rules.pro +++ b/Jetchat/app/proguard-rules.pro @@ -22,3 +22,17 @@ # Repackage classes into the top-level. -repackageclasses + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE + + +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } diff --git a/Jetchat/app/src/androidTest/AndroidManifest.xml b/Jetchat/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..f7b8095f4a --- /dev/null +++ b/Jetchat/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> +<!-- Added as workaround for https://github.com/android/android-test/issues/1412 --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + + <application> + + <activity + android:name="androidx.test.core.app.InstrumentationActivityInvoker$BootstrapActivity" + android:exported="true" + tools:node="merge"> + <intent-filter tools:node="removeAll" /> + </activity> + <activity + android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyActivity" + android:exported="true" + tools:node="merge"> + <intent-filter tools:node="removeAll" /> + </activity> + + </application> +</manifest> diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt index db5df6971c..3d6baeee74 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/ConversationTest.kt @@ -17,26 +17,21 @@ package com.example.compose.jetchat import androidx.activity.ComponentActivity -import androidx.compose.runtime.Providers -import androidx.compose.runtime.collectAsState import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.center import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performGesture +import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipe -import androidx.compose.ui.unit.milliseconds -import com.example.compose.jetchat.conversation.AmbientBackPressedDispatcher +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 dev.chrisbanes.accompanist.insets.AmbientWindowInsets -import dev.chrisbanes.accompanist.insets.WindowInsets import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule @@ -48,35 +43,20 @@ import org.junit.Test class ConversationTest { @get:Rule - val composeTestRule = createAndroidComposeRule<NavActivity>() - - // Note that keeping these references is only safe if the activity is not recreated. - // See: https://issuetracker.google.com/160862278 - private lateinit var activity: ComponentActivity + val composeTestRule = createAndroidComposeRule<ComponentActivity>() private val themeIsDark = MutableStateFlow(false) @Before fun setUp() { - composeTestRule.activityRule.scenario.onActivity { newActivity -> - activity = newActivity - // Provide empty insets. We can modify this value as necessary - val windowInsets = WindowInsets() - - // Launch the conversation screen - composeTestRule.setContent { - Providers( - AmbientBackPressedDispatcher provides newActivity.onBackPressedDispatcher, - AmbientWindowInsets provides windowInsets - ) { - JetchatTheme(isDarkTheme = themeIsDark.collectAsState(false).value) { - ConversationContent( - uiState = exampleUiState, - navigateToProfile = { }, - onNavIconPressed = { } - ) - } - } + // Launch the conversation screen + composeTestRule.setContent { + JetchatTheme(isDarkTheme = themeIsDark.collectAsStateWithLifecycle(false).value) { + ConversationContent( + uiState = conversationTestUiState, + navigateToProfile = { }, + onNavIconPressed = { } + ) } } } @@ -91,11 +71,11 @@ class ConversationTest { fun userScrollsUp_jumpToBottomAppears() { // Check list is snapped to bottom and swipe up findJumpToBottom().assertDoesNotExist() - composeTestRule.onNodeWithTag(ConversationTestTag).performGesture { + composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { this.swipe( start = this.center, end = Offset(this.center.x, this.center.y + 500), - duration = 200.milliseconds + durationMillis = 200 ) } // Check that the jump to bottom button is shown @@ -105,11 +85,11 @@ class ConversationTest { @Test fun jumpToBottom_snapsToBottomAndDisappears() { // When the scroll is not snapped to the bottom - composeTestRule.onNodeWithTag(ConversationTestTag).performGesture { + composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { this.swipe( start = this.center, end = Offset(this.center.x, this.center.y + 500), - duration = 200.milliseconds + durationMillis = 200 ) } // Snap scroll to the bottom @@ -122,11 +102,14 @@ class ConversationTest { @Test fun jumpToBottom_snapsToBottomAfterUserInteracted() { // First swipe - composeTestRule.onNodeWithTag(ConversationTestTag).performGesture { + composeTestRule.onNodeWithTag( + testTag = ConversationTestTag, + useUnmergedTree = true // https://issuetracker.google.com/issues/184825850 + ).performTouchInput { this.swipe( start = this.center, end = Offset(this.center.x, this.center.y + 500), - duration = 200.milliseconds + durationMillis = 200 ) } // Second, snap to bottom @@ -142,11 +125,11 @@ class ConversationTest { @Test fun changeTheme_scrollIsPersisted() { // Swipe to show the jump to bottom button - composeTestRule.onNodeWithTag(ConversationTestTag).performGesture { + composeTestRule.onNodeWithTag(ConversationTestTag).performTouchInput { this.swipe( start = this.center, end = Offset(this.center.x, this.center.y + 500), - duration = 200.milliseconds + durationMillis = 200 ) } @@ -161,10 +144,25 @@ class ConversationTest { } private fun findJumpToBottom() = - composeTestRule.onNodeWithText(activity.getString(R.string.jumpBottom)) + composeTestRule.onNodeWithText( + composeTestRule.activity.getString(R.string.jumpBottom), + useUnmergedTree = true + ) private fun openEmojiSelector() = composeTestRule - .onNodeWithContentDescription(activity.getString(R.string.emoji_selector_bt_desc)) + .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 index 6e1267c80e..63fb05b784 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/NavigationTest.kt @@ -16,23 +16,19 @@ package com.example.compose.jetchat -import android.view.View -import androidx.activity.ComponentActivity -import androidx.compose.runtime.Providers +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.Navigation +import androidx.navigation.findNavController import androidx.test.espresso.Espresso -import com.example.compose.jetchat.conversation.AmbientBackPressedDispatcher -import com.example.compose.jetchat.conversation.ConversationContent -import com.example.compose.jetchat.data.exampleUiState -import com.example.compose.jetchat.theme.JetchatTheme -import dev.chrisbanes.accompanist.insets.AmbientWindowInsets -import dev.chrisbanes.accompanist.insets.WindowInsets import org.junit.Assert.assertEquals -import org.junit.Before import org.junit.Rule import org.junit.Test @@ -44,58 +40,22 @@ class NavigationTest { @get:Rule val composeTestRule = createAndroidComposeRule<NavActivity>() - // Note that keeping these references is only safe if the activity is not recreated. - // See: https://issuetracker.google.com/160862278 - private lateinit var navController: NavController - private lateinit var activity: ComponentActivity - - @Before - fun setUp() { - composeTestRule.activityRule.scenario.onActivity { newActivity: NavActivity -> - // Store a reference to the activity. Don't do this if the activity is recreated! - activity = newActivity - val navHostFragment: View = newActivity.findViewById(R.id.nav_host_fragment) - // Store a reference to the navigation controller. - navController = Navigation.findNavController(navHostFragment) - } - - // Provide empty insets. We can modify this value as necessary - val windowInsets = WindowInsets() - - // Start the app - composeTestRule.setContent { - Providers( - AmbientBackPressedDispatcher provides activity.onBackPressedDispatcher, - AmbientWindowInsets provides windowInsets, - ) { - JetchatTheme { - ConversationContent( - uiState = exampleUiState, - navigateToProfile = { }, - onNavIconPressed = { } - ) - } - } - } - } - @Test fun app_launches() { // Check app launches at the correct destination - assertEquals(navController.currentDestination?.id, R.id.nav_home) + assertEquals(getNavController().currentDestination?.id, R.id.nav_home) } @Test fun profileScreen_back_conversationScreen() { - // Navigate to profile - composeTestRule.runOnUiThread { - navController.navigate(R.id.nav_profile) - } + 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(activity.getString(R.string.textfield_hint)) + .onNodeWithText(composeTestRule.activity.getString(R.string.display_name)) .assertIsDisplayed() // Press back @@ -104,4 +64,45 @@ class NavigationTest { // 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 index 771372d709..cd601860bb 100644 --- a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/UserInputTest.kt @@ -17,7 +17,6 @@ package com.example.compose.jetchat import androidx.activity.ComponentActivity -import androidx.compose.runtime.Providers import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsDisplayed @@ -27,19 +26,18 @@ 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.AmbientBackPressedDispatcher 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 dev.chrisbanes.accompanist.insets.AmbientWindowInsets -import dev.chrisbanes.accompanist.insets.WindowInsets import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test @@ -49,38 +47,26 @@ import org.junit.Test class UserInputTest { @get:Rule - val composeTestRule = createAndroidComposeRule<NavActivity>() + val composeTestRule = createAndroidComposeRule<ComponentActivity>() - // Note that keeping these references is only safe if the activity is not recreated. - // See: https://issuetracker.google.com/160862278 - private lateinit var activity: ComponentActivity + private val activity by lazy { composeTestRule.activity } @Before fun setUp() { - composeTestRule.activityRule.scenario.onActivity { newActivity -> - activity = newActivity - // Provide empty insets. We can modify this value as necessary - val windowInsets = WindowInsets() - - // Launch the conversation screen - composeTestRule.setContent { - Providers( - AmbientBackPressedDispatcher provides activity.onBackPressedDispatcher, - AmbientWindowInsets provides windowInsets, - ) { - JetchatTheme { - ConversationContent( - uiState = exampleUiState, - navigateToProfile = { }, - onNavIconPressed = { } - ) - } - } + // 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() @@ -91,6 +77,15 @@ class UserInputTest { .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() } @@ -125,6 +120,7 @@ class UserInputTest { } @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. @@ -144,7 +140,10 @@ class UserInputTest { private fun openEmojiSelector() = composeTestRule - .onNodeWithContentDescription(activity.getString(R.string.emoji_selector_bt_desc)) + .onNodeWithContentDescription( + label = activity.getString(R.string.emoji_selector_bt_desc), + useUnmergedTree = true // https://issuetracker.google.com/issues/184825850 + ) .performClick() private fun assertEmojiSelectorIsDisplayed() = diff --git a/Jetchat/app/src/main/AndroidManifest.xml b/Jetchat/app/src/main/AndroidManifest.xml index d0f258d68b..b4ea01d944 100644 --- a/Jetchat/app/src/main/AndroidManifest.xml +++ b/Jetchat/app/src/main/AndroidManifest.xml @@ -15,24 +15,34 @@ ~ limitations under the License. --> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.example.compose.jetchat"> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <application android:allowBackup="true" + android:enableOnBackInvokedCallback="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.Jetchat.NoActionBar"> + <activity android:name=".NavActivity" android:windowSoftInputMode="adjustResize" - android:label="@string/app_name"> + android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> + <receiver android:name=".widget.WidgetReceiver" + android:exported="true"> + <intent-filter> + <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> + </intent-filter> + <meta-data + android:name="android.appwidget.provider" + android:resource="@xml/widget_unread_messages_info" /> + </receiver> </application> </manifest> diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt index 30ec946e8a..51bb6d040a 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/MainViewModel.kt @@ -16,21 +16,22 @@ package com.example.compose.jetchat -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow /** * Used to communicate between screens. */ class MainViewModel : ViewModel() { - private val _drawerShouldBeOpened = MutableLiveData(false) - val drawerShouldBeOpened: LiveData<Boolean> = _drawerShouldBeOpened + private val _drawerShouldBeOpened = MutableStateFlow(false) + val drawerShouldBeOpened = _drawerShouldBeOpened.asStateFlow() fun openDrawer() { _drawerShouldBeOpened.value = true } + fun resetOpenDrawerAction() { _drawerShouldBeOpened.value = false } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt index b34eaf7fc1..f395a57356 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/NavActivity.kt @@ -17,21 +17,29 @@ 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.material.rememberScaffoldState -import androidx.compose.runtime.Providers -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.platform.setContent +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.WindowCompat -import androidx.navigation.findNavController -import com.example.compose.jetchat.components.JetchatScaffold -import com.example.compose.jetchat.conversation.AmbientBackPressedDispatcher -import com.example.compose.jetchat.conversation.BackPressHandler +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 dev.chrisbanes.accompanist.insets.ProvideWindowInsets +import kotlinx.coroutines.launch /** * Main activity for the app. @@ -39,59 +47,71 @@ import dev.chrisbanes.accompanist.insets.ProvideWindowInsets class NavActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets -> insets } - // Turn off the decor fitting system windows, which allows us to handle insets, - // including IME animations - WindowCompat.setDecorFitsSystemWindows(window, false) + setContentView( + ComposeView(this).apply { + consumeWindowInsets = false + setContent { + val drawerState = rememberDrawerState(initialValue = Closed) + val drawerOpen by viewModel.drawerShouldBeOpened + .collectAsStateWithLifecycle() - setContent { - // Provide WindowInsets to our content. We don't want to consume them, so that - // they keep being pass down the view hierarchy (since we're using fragments). - ProvideWindowInsets(consumeWindowInsets = false) { - Providers(AmbientBackPressedDispatcher provides this.onBackPressedDispatcher) { - val scaffoldState = rememberScaffoldState() - - val openDrawerEvent = viewModel.drawerShouldBeOpened.observeAsState() - if (openDrawerEvent.value == true) { + var selectedMenu by remember { mutableStateOf("composers") } + if (drawerOpen) { // Open drawer and reset state in VM. - scaffoldState.drawerState.open { - viewModel.resetOpenDrawerAction() + LaunchedEffect(Unit) { + // wrap in try-finally to handle interruption whiles opening drawer + try { + drawerState.open() + } finally { + viewModel.resetOpenDrawerAction() + } } } - // Intercepts back navigation when the drawer is open - if (scaffoldState.drawerState.isOpen) { - BackPressHandler { scaffoldState.drawerState.close() } - } + val scope = rememberCoroutineScope() - JetchatScaffold( - scaffoldState, + JetchatDrawer( + drawerState = drawerState, + selectedMenu = selectedMenu, onChatClicked = { - findNavController(R.id.nav_host_fragment) - .popBackStack(R.id.nav_home, true) - scaffoldState.drawerState.close() + findNavController().popBackStack(R.id.nav_home, false) + scope.launch { + drawerState.close() + } + selectedMenu = it }, onProfileClicked = { val bundle = bundleOf("userId" to it) - findNavController(R.id.nav_host_fragment).navigate( - R.id.nav_profile, - bundle - ) - scaffoldState.drawerState.close() + findNavController().navigate(R.id.nav_profile, bundle) + scope.launch { + drawerState.close() + } + selectedMenu = it } ) { - // Inflate the XML layout using View Binding: AndroidViewBinding(ContentMainBinding::inflate) } } } - } + ) } override fun onSupportNavigateUp(): Boolean { - val navController = findNavController(R.id.nav_host_fragment) - return navController.navigateUp() || super.onSupportNavigateUp() + 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 index ff3ae21393..a374c4107b 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/UiExtras.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/UiExtras.kt @@ -16,10 +16,10 @@ package com.example.compose.jetchat -import androidx.compose.material.AlertDialog -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextButton +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @Composable @@ -29,7 +29,7 @@ fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) { text = { Text( text = "Functionality not available \uD83D\uDE48", - style = MaterialTheme.typography.body2 + style = MaterialTheme.typography.bodyMedium ) }, confirmButton = { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt index af6ab7b481..d4617b6f85 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/AnimatingFabContent.kt @@ -17,16 +17,16 @@ package com.example.compose.jetchat.components import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.FloatPropKey import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.transitionDefinition +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween -import androidx.compose.animation.transition +import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +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 @@ -43,17 +43,60 @@ fun AnimatingFabContent( extended: Boolean = true ) { val currentState = if (extended) ExpandableFabStates.Extended else ExpandableFabStates.Collapsed - val transitionDefinition = remember { fabTransitionDefinition() } - val transition = transition( - definition = transitionDefinition, - toState = currentState - ) - // Using functions instead of Floats here can improve performance, preventing recompositions. + 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, - { transition[TextOpacity] }, - { transition[FabWidthFactor] }, + { textOpacity }, + { fabWidthFactor }, modifier = modifier ) } @@ -62,7 +105,7 @@ fun AnimatingFabContent( private fun IconAndTextRow( icon: @Composable () -> Unit, text: @Composable () -> Unit, - opacityProgress: () -> Float, // Functions instead of Floats, to slightly improve performance + opacityProgress: () -> Float, // Lambdas instead of Floats, to defer read widthProgress: () -> Float, modifier: Modifier ) { @@ -70,7 +113,7 @@ private fun IconAndTextRow( modifier = modifier, content = { icon() - Box(modifier = Modifier.alpha(opacityProgress())) { + Box(modifier = Modifier.graphicsLayer { alpha = opacityProgress() }) { text() } } @@ -106,47 +149,6 @@ private fun IconAndTextRow( } } -private val FabWidthFactor = FloatPropKey("Width") -private val TextOpacity = FloatPropKey("Text Opacity") - private enum class ExpandableFabStates { Collapsed, Extended } -@Suppress("RemoveExplicitTypeArguments") -private fun fabTransitionDefinition(duration: Int = 200) = - transitionDefinition<ExpandableFabStates> { - state(ExpandableFabStates.Collapsed) { - this[FabWidthFactor] = 0f - this[TextOpacity] = 0f - } - state(ExpandableFabStates.Extended) { - this[FabWidthFactor] = 1f - this[TextOpacity] = 1f - } - transition( - fromState = ExpandableFabStates.Extended, - toState = ExpandableFabStates.Collapsed - ) { - TextOpacity using tween<Float>( - easing = LinearEasing, - durationMillis = (duration / 12f * 5).roundToInt() // 5 out of 12 frames - ) - FabWidthFactor using tween<Float>( - easing = FastOutSlowInEasing, - durationMillis = duration - ) - } - transition( - fromState = ExpandableFabStates.Collapsed, - toState = ExpandableFabStates.Extended - ) { - TextOpacity using tween<Float>( - easing = LinearEasing, - delayMillis = (duration / 3f).roundToInt(), // 4 out of 12 frames - durationMillis = (duration / 12f * 5).roundToInt() // 5 out of 12 frames - ) - FabWidthFactor using tween<Float>( - easing = FastOutSlowInEasing, - durationMillis = duration - ) - } - } +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 index e15486fc19..c0f731aa0d 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/BaseLineHeightModifier.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/BaseLineHeightModifier.kt @@ -52,9 +52,9 @@ data class BaselineHeightModifier( val firstBaseline = textPlaceable[FirstBaseline] val lastBaseline = textPlaceable[LastBaseline] - val height = heightFromBaseline.toIntPx() + lastBaseline - firstBaseline + val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline return layout(constraints.maxWidth, height) { - val topY = heightFromBaseline.toIntPx() - firstBaseline + val topY = heightFromBaseline.roundToPx() - firstBaseline textPlaceable.place(0, topY) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt index d71730f7ad..9d221bd6fa 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt @@ -14,66 +14,53 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.example.compose.jetchat.components -import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.compose.jetchat.R import com.example.compose.jetchat.theme.JetchatTheme -import com.example.compose.jetchat.theme.elevatedSurface +@OptIn(ExperimentalMaterial3Api::class) @Composable fun JetchatAppBar( modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = null, onNavIconPressed: () -> Unit = { }, - title: @Composable RowScope.() -> Unit, + title: @Composable () -> Unit, actions: @Composable RowScope.() -> Unit = {} ) { - // This bar is translucent but elevation overlays are not applied to translucent colors. - // Instead we manually calculate the elevated surface color from the opaque color, - // then apply our alpha. - // - // We set the background on the Column rather than the TopAppBar, - // so that the background is drawn behind any padding set on the app bar (i.e. status bar). - val backgroundColor = MaterialTheme.colors.elevatedSurface(3.dp) - Column( - Modifier.background(backgroundColor.copy(alpha = 0.95f)) - ) { - TopAppBar( - modifier = modifier, - backgroundColor = Color.Transparent, - elevation = 0.dp, // No shadow needed - contentColor = MaterialTheme.colors.onSurface, - actions = actions, - title = { Row { title() } }, // https://issuetracker.google.com/168793068 - navigationIcon = { - Image( - imageVector = vectorResource(id = R.drawable.ic_jetchat), - modifier = Modifier - .clickable(onClick = onNavIconPressed) - .padding(horizontal = 16.dp) - ) - } - ) - Divider() - } + CenterAlignedTopAppBar( + modifier = modifier, + actions = actions, + title = title, + scrollBehavior = scrollBehavior, + navigationIcon = { + JetchatIcon( + contentDescription = stringResource(id = R.string.navigation_drawer_open), + modifier = Modifier + .size(64.dp) + .clickable(onClick = onNavIconPressed) + .padding(16.dp) + ) + } + ) } +@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable fun JetchatAppBarPreview() { @@ -82,6 +69,7 @@ fun JetchatAppBarPreview() { } } +@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable fun JetchatAppBarPreviewDark() { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt index 48ee88998d..dea971f692 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatDrawer.kt @@ -16,165 +16,275 @@ package com.example.compose.jetchat.components +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers +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.res.imageResource -import androidx.compose.ui.res.vectorResource +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 dev.chrisbanes.accompanist.insets.statusBarsHeight +import com.example.compose.jetchat.widget.WidgetReceiver @Composable -fun ColumnScope.JetchatDrawer(onProfileClicked: (String) -> Unit, onChatClicked: (String) -> Unit) { - // Use statusBarsHeight() to add a spacer which pushes the drawer content +fun JetchatDrawerContent( + onProfileClicked: (String) -> Unit, + onChatClicked: (String) -> Unit, + selectedMenu: String = "composers" +) { + // Use windowInsetsTopHeight() to add a spacer which pushes the drawer content // below the status bar (y-axis) - Spacer(Modifier.statusBarsHeight()) - DrawerHeader() - Divider() - DrawerItemHeader("Chats") - ChatItem("composers", true) { onChatClicked("composers") } - ChatItem("droidcon-nyc", false) { onChatClicked("droidcon-nyc") } - DrawerItemHeader("Recent Profiles") - ProfileItem("Ali Conors (you)", meProfile.photo) { onProfileClicked(meProfile.userId) } - ProfileItem("Taylor Brooks", colleagueProfile.photo) { - onProfileClicked(colleagueProfile.userId) + Column { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars)) + DrawerHeader() + DividerItem() + DrawerItemHeader("Chats") + ChatItem("composers", selectedMenu == "composers") { + onChatClicked("composers") + } + ChatItem("droidcon-nyc", selectedMenu == "droidcon-nyc") { + onChatClicked("droidcon-nyc") + } + DividerItem(modifier = Modifier.padding(horizontal = 28.dp)) + DrawerItemHeader("Recent Profiles") + ProfileItem( + "Ali Conors (you)", meProfile.photo, + selectedMenu == meProfile.userId + ) { + onProfileClicked(meProfile.userId) + } + ProfileItem( + "Taylor Brooks", colleagueProfile.photo, + selectedMenu == colleagueProfile.userId + ) { + onProfileClicked(colleagueProfile.userId) + } + if (widgetAddingIsSupported(LocalContext.current)) { + DividerItem(modifier = Modifier.padding(horizontal = 28.dp)) + DrawerItemHeader("Settings") + WidgetDiscoverability() + } } } @Composable private fun DrawerHeader() { Row(modifier = Modifier.padding(16.dp), verticalAlignment = CenterVertically) { - Image( - vectorResource(id = R.drawable.ic_jetchat), - modifier = Modifier.preferredSize(24.dp) + JetchatIcon( + contentDescription = null, + modifier = Modifier.size(24.dp) ) Image( - vectorResource(id = R.drawable.jetchat_logo), + painter = painterResource(id = R.drawable.jetchat_logo), + contentDescription = null, modifier = Modifier.padding(start = 8.dp) ) } } + @Composable private fun DrawerItemHeader(text: String) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text(text, style = MaterialTheme.typography.caption, modifier = Modifier.padding(16.dp)) + Box( + modifier = Modifier + .heightIn(min = 52.dp) + .padding(horizontal = 28.dp), + contentAlignment = CenterStart + ) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } @Composable private fun ChatItem(text: String, selected: Boolean, onChatClicked: () -> Unit) { val background = if (selected) { - Modifier.background(MaterialTheme.colors.primary.copy(alpha = 0.08f)) + Modifier.background(MaterialTheme.colorScheme.primaryContainer) } else { Modifier } Row( modifier = Modifier - .preferredHeight(48.dp) + .height(56.dp) .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(horizontal = 12.dp) + .clip(CircleShape) .then(background) - .clip(MaterialTheme.shapes.medium) .clickable(onClick = onChatClicked), verticalAlignment = CenterVertically ) { val iconTint = if (selected) { - MaterialTheme.colors.primary + MaterialTheme.colorScheme.primary } else { - MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium) + MaterialTheme.colorScheme.onSurfaceVariant } Icon( - vectorResource(id = R.drawable.ic_jetchat), + painter = painterResource(id = R.drawable.ic_jetchat), tint = iconTint, - modifier = Modifier.padding(8.dp) + 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) ) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text, - style = MaterialTheme.typography.body2, - color = if (selected) MaterialTheme.colors.primary else AmbientContentColor.current, - modifier = Modifier.padding(8.dp) - ) - } } } @Composable -private fun ProfileItem(text: String, @DrawableRes profilePic: Int?, onProfileClicked: () -> Unit) { +private fun ProfileItem( + text: String, + @DrawableRes profilePic: Int?, + selected: Boolean = false, + onProfileClicked: () -> Unit +) { + val background = if (selected) { + Modifier.background(MaterialTheme.colorScheme.primaryContainer) + } else { + Modifier + } Row( modifier = Modifier - .preferredHeight(48.dp) + .height(56.dp) .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) - .clip(MaterialTheme.shapes.medium) + .padding(horizontal = 12.dp) + .clip(CircleShape) + .then(background) .clickable(onClick = onProfileClicked), verticalAlignment = CenterVertically ) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - val widthPaddingModifier = Modifier.preferredWidth(24.dp).padding(8.dp) - if (profilePic != null) { - Image( - imageResource(id = profilePic), - modifier = widthPaddingModifier.then(Modifier.clip(CircleShape)), - contentScale = ContentScale.Crop - ) - } else { - Spacer(modifier = widthPaddingModifier) - } - Text(text, style = MaterialTheme.typography.body2, modifier = Modifier.padding(8.dp)) + val paddingSizeModifier = Modifier + .padding(start = 16.dp, top = 16.dp, bottom = 16.dp) + .size(24.dp) + if (profilePic != null) { + Image( + painter = painterResource(id = profilePic), + modifier = paddingSizeModifier.then(Modifier.clip(CircleShape)), + contentScale = ContentScale.Crop, + contentDescription = null + ) + } else { + Spacer(modifier = paddingSizeModifier) } + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 12.dp) + ) } } +@Composable +fun DividerItem(modifier: Modifier = Modifier) { + HorizontalDivider( + modifier = modifier, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) +} + @Composable @Preview fun DrawerPreview() { JetchatTheme { Surface { Column { - JetchatDrawer({}, {}) + JetchatDrawerContent({}, {}) } } } } + @Composable @Preview fun DrawerPreviewDark() { JetchatTheme(isDarkTheme = true) { Surface { Column { - JetchatDrawer({}, {}) + JetchatDrawerContent({}, {}) } } } } + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun WidgetDiscoverability() { + val context = LocalContext.current + Row( + modifier = Modifier + .height(56.dp) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(CircleShape) + .clickable(onClick = { + addWidgetToHomeScreen(context) + }), + verticalAlignment = CenterVertically + ) { + Text( + stringResource(id = R.string.add_widget_to_home_page), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 12.dp) + ) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun addWidgetToHomeScreen(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val myProvider = ComponentName(context, WidgetReceiver::class.java) + if (widgetAddingIsSupported(context)) { + appWidgetManager.requestPinAppWidget(myProvider, null, null) + } +} + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) +private fun widgetAddingIsSupported(context: Context): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + AppWidgetManager.getInstance(context).isRequestPinAppWidgetSupported +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatIcon.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatIcon.kt new file mode 100644 index 0000000000..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 index 69b62a80d1..df78ec2616 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatScaffold.kt @@ -16,30 +16,40 @@ package com.example.compose.jetchat.components -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.material.Scaffold -import androidx.compose.material.ScaffoldState -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue.Closed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import com.example.compose.jetchat.theme.JetchatTheme @Composable -fun JetchatScaffold( - scaffoldState: ScaffoldState = rememberScaffoldState(), +fun JetchatDrawer( + drawerState: DrawerState = rememberDrawerState(initialValue = Closed), + selectedMenu: String, onProfileClicked: (String) -> Unit, onChatClicked: (String) -> Unit, - content: @Composable (PaddingValues) -> Unit + content: @Composable () -> Unit, ) { JetchatTheme { - Scaffold( - scaffoldState = scaffoldState, + ModalNavigationDrawer( + drawerState = drawerState, drawerContent = { - JetchatDrawer( - onProfileClicked = onProfileClicked, - onChatClicked = onChatClicked - ) + ModalDrawerSheet( + drawerState = drawerState, + drawerContainerColor = MaterialTheme.colorScheme.background, + drawerContentColor = MaterialTheme.colorScheme.onBackground, + ) { + JetchatDrawerContent( + onProfileClicked = onProfileClicked, + onChatClicked = onChatClicked, + selectedMenu = selectedMenu + ) + } }, - bodyContent = content + content = content ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt deleted file mode 100644 index 393d542c31..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/BackHandler.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.conversation - -import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.runtime.Ambient -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.staticAmbientOf - -/** - * This [Composable] can be used with a [AmbientBackPressedDispatcher] to intercept a back press. - * - * @param onBackPressed (Event) What to do when back is intercepted - * - */ -@Composable -fun BackPressHandler(onBackPressed: () -> Unit) { - // Safely update the current `onBack` lambda when a new one is provided - val currentOnBackPressed by rememberUpdatedState(onBackPressed) - - // Remember in Composition a back callback that calls the `onBackPressed` lambda - val backCallback = remember { - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - currentOnBackPressed() - } - } - } - - val backDispatcher = AmbientBackPressedDispatcher.current - - // Whenever there's a new dispatcher set up the callback - DisposableEffect(backDispatcher) { - backDispatcher.addCallback(backCallback) - // When the effect leaves the Composition, or there's a new dispatcher, remove the callback - onDispose { - backCallback.remove() - } - } -} - -/** - * This [Ambient] is used to provide an [OnBackPressedDispatcher]: - * - * ``` - * Providers(AmbientBackPressedDispatcher provides requireActivity().onBackPressedDispatcher) { } - * ``` - * - * and setting up the callbacks with [BackPressHandler]. - */ -val AmbientBackPressedDispatcher = - staticAmbientOf<OnBackPressedDispatcher> { error("No Back Dispatcher provided") } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 6b0479f776..283e7f8b7f 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -14,64 +14,87 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.example.compose.jetchat.conversation -import androidx.compose.foundation.ClickableText +import android.content.ClipDescription +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.ScrollableColumn +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.fillMaxWidth 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.preferredHeight -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.foundation.rememberScrollState +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.material.AmbientContentAlpha -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +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.Providers +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.AmbientDensity -import androidx.compose.ui.platform.AmbientUriHandler +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.imageResource +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 com.example.compose.jetchat.theme.elevatedSurface -import dev.chrisbanes.accompanist.insets.navigationBarsWithImePadding -import dev.chrisbanes.accompanist.insets.statusBarsPadding +import kotlinx.coroutines.launch /** * Entry point for a conversation screen. @@ -81,6 +104,7 @@ import dev.chrisbanes.accompanist.insets.statusBarsPadding * @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, @@ -91,88 +115,164 @@ fun ConversationContent( val authorMe = stringResource(R.string.author_me) val timeNow = stringResource(id = R.string.now) - val scrollState = rememberScrollState() - Surface(modifier = modifier) { - Box(modifier = Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize()) { - Messages( - messages = uiState.messages, - navigateToProfile = navigateToProfile, - modifier = Modifier.weight(1f), - scrollState = scrollState - ) - UserInput( - onMessageSent = { content -> - uiState.addMessage( - Message(authorMe, content, timeNow) - ) - }, - scrollState = scrollState, - // Use navigationBarsWithImePadding(), to move the input panel above both the - // navigation bar, and on-screen keyboard (IME) - modifier = Modifier.navigationBarsWithImePadding(), + 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) } - // Channel name bar floats above the messages + + override fun onExited(event: DragAndDropEvent) { + super.onExited(event) + background = Color.Transparent + } + + override fun onEnded(event: DragAndDropEvent) { + super.onEnded(event) + background = Color.Transparent + borderStroke = Color.Transparent + } + } + } + + Scaffold( + topBar = { ChannelNameBar( channelName = uiState.channelName, channelMembers = uiState.channelMembers, onNavIconPressed = onNavIconPressed, - // Use statusBarsPadding() to move the app bar content below the status bar - modifier = Modifier.statusBarsPadding(), + scrollBehavior = scrollBehavior, + ) + }, + // Exclude ime and navigation bar padding so this can be added by the UserInput composable + contentWindowInsets = ScaffoldDefaults + .contentWindowInsets + .exclude(WindowInsets.navigationBars) + .exclude(WindowInsets.ime), + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) { paddingValues -> + Column( + Modifier.fillMaxSize().padding(paddingValues) + .background(color = background) + .border(width = 2.dp, color = borderStroke) + .dragAndDropTarget(shouldStartDragAndDrop = { event -> + event + .mimeTypes() + .contains( + ClipDescription.MIMETYPE_TEXT_PLAIN + ) + }, target = dragAndDropCallback) + ) { + Messages( + messages = uiState.messages, + navigateToProfile = navigateToProfile, + modifier = Modifier.weight(1f), + scrollState = scrollState + ) + UserInput( + onMessageSent = { content -> + uiState.addMessage( + Message(authorMe, content, timeNow) + ) + }, + resetScroll = { + scope.launch { + scrollState.scrollToItem(0) + } + }, + // let this element handle the padding so that the elevation is shown behind the + // navigation bar + modifier = Modifier.navigationBarsPadding().imePadding() ) } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChannelNameBar( channelName: String, channelMembers: Int, modifier: Modifier = Modifier, + scrollBehavior: TopAppBarScrollBehavior? = null, onNavIconPressed: () -> Unit = { } ) { + var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } + if (functionalityNotAvailablePopupShown) { + FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } + } JetchatAppBar( modifier = modifier, + scrollBehavior = scrollBehavior, onNavIconPressed = onNavIconPressed, title = { - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { // Channel name Text( text = channelName, - style = MaterialTheme.typography.subtitle1 + style = MaterialTheme.typography.titleMedium ) // Number of members - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(R.string.members, channelMembers), - style = MaterialTheme.typography.caption - ) - } + Text( + text = stringResource(R.string.members, channelMembers), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } }, actions = { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - // Search icon - Icon( - imageVector = Icons.Outlined.Search, - modifier = Modifier - .clickable(onClick = {}) // TODO: Show not implemented dialog. - .padding(horizontal = 12.dp, vertical = 16.dp) - .preferredHeight(24.dp) - ) - // Info icon - Icon( - imageVector = Icons.Outlined.Info, - modifier = Modifier - .clickable(onClick = {}) // TODO: Show not implemented dialog. - .padding(horizontal = 12.dp, vertical = 16.dp) - .preferredHeight(24.dp) - ) - } + // 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) + ) } ) } @@ -183,58 +283,71 @@ const val ConversationTestTag = "ConversationTestTag" fun Messages( messages: List<Message>, navigateToProfile: (String) -> Unit, - scrollState: ScrollState, + scrollState: LazyListState, modifier: Modifier = Modifier ) { + val scope = rememberCoroutineScope() Box(modifier = modifier) { - ScrollableColumn( - scrollState = scrollState, - reverseScrollDirection = true, + val authorMe = stringResource(id = R.string.author_me) + LazyColumn( + reverseLayout = true, + state = scrollState, modifier = Modifier .testTag(ConversationTestTag) - .fillMaxWidth() + .fillMaxSize() ) { - val authorMe = stringResource(id = R.string.author_me) - Spacer(modifier = Modifier.preferredHeight(64.dp)) - messages.forEachIndexed { index, content -> + 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 == 0) { - DayHeader("20 Aug") - } else if (index == 4) { - DayHeader("Today") + if (index == messages.size - 1) { + item { + DayHeader("20 Aug") + } + } else if (index == 2) { + item { + DayHeader("Today") + } } - Message( - onAuthorClick = { - navigateToProfile(content.author) - }, - msg = content, - isUserMe = content.author == authorMe, - isFirstMessageByAuthor = isFirstMessageByAuthor, - isLastMessageByAuthor = isLastMessageByAuthor - ) + 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(AmbientDensity.current) { + val jumpThreshold = with(LocalDensity.current) { JumpToBottomThreshold.toPx() } - // Apply the threshold: - val jumpToBottomButtonEnabled = scrollState.value > jumpThreshold + // 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 = { - scrollState.smoothScrollTo(BottomScrollState) + scope.launch { + scrollState.animateScrollToItem(0) + } }, modifier = Modifier.align(Alignment.BottomCenter) ) @@ -243,48 +356,45 @@ fun Messages( @Composable fun Message( - onAuthorClick: () -> Unit, + onAuthorClick: (String) -> Unit, msg: Message, isUserMe: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean ) { - // TODO: get image from msg.author - val image = if (isUserMe) { - imageResource(id = R.drawable.ali) - } else { - imageResource(id = R.drawable.someone_else) - } val borderColor = if (isUserMe) { - MaterialTheme.colors.primary + MaterialTheme.colorScheme.primary } else { - MaterialTheme.colors.secondary + MaterialTheme.colorScheme.tertiary } - val spaceBetweenAuthors = if (isFirstMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier + val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier Row(modifier = spaceBetweenAuthors) { - if (isFirstMessageByAuthor) { + if (isLastMessageByAuthor) { // Avatar Image( modifier = Modifier - .clickable(onClick = onAuthorClick) + .clickable(onClick = { onAuthorClick(msg.author) }) .padding(horizontal = 16.dp) - .preferredSize(42.dp) + .size(42.dp) .border(1.5.dp, borderColor, CircleShape) - .border(3.dp, MaterialTheme.colors.surface, CircleShape) + .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) .clip(CircleShape) .align(Alignment.Top), - bitmap = image, - contentScale = ContentScale.Crop + painter = painterResource(id = msg.authorImage), + contentScale = ContentScale.Crop, + contentDescription = null, ) } else { // Space under avatar - Spacer(modifier = Modifier.preferredWidth(74.dp)) + 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) @@ -295,21 +405,23 @@ fun Message( @Composable fun AuthorAndTextMessage( msg: Message, + isUserMe: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, + authorClicked: (String) -> Unit, modifier: Modifier = Modifier ) { Column(modifier = modifier) { - if (isFirstMessageByAuthor) { + if (isLastMessageByAuthor) { AuthorNameTimestamp(msg) } - ChatItemBubble(msg, isLastMessageByAuthor) - if (isLastMessageByAuthor) { + ChatItemBubble(msg, isUserMe, authorClicked = authorClicked) + if (isFirstMessageByAuthor) { // Last bubble before next author - Spacer(modifier = Modifier.preferredHeight(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) } else { // Between bubbles - Spacer(modifier = Modifier.preferredHeight(4.dp)) + Spacer(modifier = Modifier.height(4.dp)) } } } @@ -320,36 +432,37 @@ private fun AuthorNameTimestamp(msg: Message) { Row(modifier = Modifier.semantics(mergeDescendants = true) {}) { Text( text = msg.author, - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, modifier = Modifier .alignBy(LastBaseline) .paddingFrom(LastBaseline, after = 8.dp) // Space to 1st bubble ) - Spacer(modifier = Modifier.preferredWidth(8.dp)) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = msg.timestamp, - style = MaterialTheme.typography.caption, - modifier = Modifier.alignBy(LastBaseline) - ) - } + 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(0.dp, 8.dp, 8.dp, 0.dp) -private val LastChatBubbleShape = RoundedCornerShape(0.dp, 8.dp, 8.dp, 8.dp) +private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp) @Composable fun DayHeader(dayString: String) { - Row(modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp).preferredHeight(16.dp)) { + Row( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + .height(16.dp) + ) { DayHeaderLine() - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = dayString, - modifier = Modifier.padding(horizontal = 16.dp), - style = MaterialTheme.typography.overline - ) - } + Text( + text = dayString, + modifier = Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) DayHeaderLine() } } @@ -357,39 +470,49 @@ fun DayHeader(dayString: String) { @Composable private fun RowScope.DayHeaderLine() { Divider( - modifier = Modifier.weight(1f).align(Alignment.CenterVertically), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) ) } @Composable fun ChatItemBubble( message: Message, - lastMessageByAuthor: Boolean + isUserMe: Boolean, + authorClicked: (String) -> Unit ) { - val backgroundBubbleColor = - if (MaterialTheme.colors.isLight) { - Color(0xFFF5F5F5) - } else { - MaterialTheme.colors.elevatedSurface(2.dp) - } + val backgroundBubbleColor = if (isUserMe) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } - val bubbleShape = if (lastMessageByAuthor) LastChatBubbleShape else ChatBubbleShape Column { - Surface(color = backgroundBubbleColor, shape = bubbleShape) { + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape + ) { ClickableMessage( - message = message + message = message, + isUserMe = isUserMe, + authorClicked = authorClicked ) } message.image?.let { Spacer(modifier = Modifier.height(4.dp)) - Surface(color = backgroundBubbleColor, shape = bubbleShape) { + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape + ) { Image( - bitmap = imageResource(it), + painter = painterResource(it), contentScale = ContentScale.Fit, - modifier = Modifier.preferredSize(160.dp) + modifier = Modifier.size(160.dp), + contentDescription = stringResource(id = R.string.attached_image) ) } } @@ -397,15 +520,22 @@ fun ChatItemBubble( } @Composable -fun ClickableMessage(message: Message) { - val uriHandler = AmbientUriHandler.current +fun ClickableMessage( + message: Message, + isUserMe: Boolean, + authorClicked: (String) -> Unit +) { + val uriHandler = LocalUriHandler.current - val styledMessage = messageFormatter(text = message.content) + val styledMessage = messageFormatter( + text = message.content, + primary = isUserMe + ) ClickableText( text = styledMessage, - style = MaterialTheme.typography.body1.copy(color = AmbientContentColor.current), - modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), + modifier = Modifier.padding(16.dp), onClick = { styledMessage .getStringAnnotations(start = it, end = it) @@ -413,8 +543,7 @@ fun ClickableMessage(message: Message) { ?.let { annotation -> when (annotation.tag) { SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item) - // TODO(yrezgui): Open profile screen when click PERSON tag - // (e.g. @aliconors) + SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item) else -> Unit } } @@ -435,7 +564,7 @@ fun ConversationPreview() { @Preview @Composable -fun channelBarPrev() { +fun ChannelBarPrev() { JetchatTheme { ChannelNameBar(channelName = "composers", channelMembers = 52) } @@ -448,6 +577,3 @@ fun DayHeaderPrev() { } private val JumpToBottomThreshold = 56.dp -private val BottomScrollState = 0f - -private fun ScrollState.atBottom(): Boolean = value == BottomScrollState diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt index af9a7ba557..efdc12401f 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationFragment.kt @@ -22,7 +22,6 @@ import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import androidx.compose.runtime.Providers import androidx.compose.ui.platform.ComposeView import androidx.core.os.bundleOf import androidx.fragment.app.Fragment @@ -32,15 +31,11 @@ import com.example.compose.jetchat.MainViewModel import com.example.compose.jetchat.R import com.example.compose.jetchat.data.exampleUiState import com.example.compose.jetchat.theme.JetchatTheme -import dev.chrisbanes.accompanist.insets.AmbientWindowInsets -import dev.chrisbanes.accompanist.insets.ExperimentalAnimatedInsets -import dev.chrisbanes.accompanist.insets.ViewWindowInsetObserver class ConversationFragment : Fragment() { private val activityViewModel: MainViewModel by activityViewModels() - @OptIn(ExperimentalAnimatedInsets::class) // Opt-in to experiment animated insets support override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -48,36 +43,22 @@ class ConversationFragment : Fragment() { ): View = ComposeView(inflater.context).apply { layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) - // Create a ViewWindowInsetObserver using this view, and call start() to - // start listening now. The WindowInsets instance is returned, allowing us to - // provide it to AmbientWindowInsets in our content below. - val windowInsets = ViewWindowInsetObserver(this) - // We use the `windowInsetsAnimationsEnabled` parameter to enable animated - // insets support. This allows our `ConversationContent` to animate with the - // on-screen keyboard (IME) as it enters/exits the screen. - .start(windowInsetsAnimationsEnabled = true) - setContent { - Providers( - AmbientBackPressedDispatcher provides requireActivity().onBackPressedDispatcher, - AmbientWindowInsets provides windowInsets, - ) { - JetchatTheme { - ConversationContent( - uiState = exampleUiState, - navigateToProfile = { user -> - // Click callback - val bundle = bundleOf("userId" to user) - findNavController().navigate( - R.id.nav_profile, - bundle - ) - }, - onNavIconPressed = { - activityViewModel.openDrawer() - } - ) - } + 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 index 3d9baed149..edd61c738d 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt @@ -17,19 +17,19 @@ package com.example.compose.jetchat.conversation import androidx.compose.runtime.Immutable -import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.toMutableStateList +import com.example.compose.jetchat.R class ConversationUiState( val channelName: String, val channelMembers: Int, initialMessages: List<Message> ) { - private val _messages: MutableList<Message> = - mutableStateListOf(*initialMessages.toTypedArray()) + private val _messages: MutableList<Message> = initialMessages.toMutableStateList() val messages: List<Message> = _messages fun addMessage(msg: Message) { - _messages.add(msg) + _messages.add(0, msg) // Add to the beginning of the list } } @@ -38,5 +38,6 @@ data class Message( val author: String, val content: String, val timestamp: String, - val image: Int? = null + val image: Int? = null, + val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else, ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt index 61155211ab..dfe517e03e 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/JumpToBottom.kt @@ -16,35 +16,24 @@ package com.example.compose.jetchat.conversation -import androidx.compose.animation.DpPropKey -import androidx.compose.animation.core.transitionDefinition -import androidx.compose.animation.transition +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.foundation.layout.preferredHeight -import androidx.compose.material.ExtendedFloatingActionButton -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.compose.jetchat.R -private val bottomOffset = DpPropKey("Bottom Offset") - -private val definition = transitionDefinition<Visibility> { - state(Visibility.GONE) { - this[bottomOffset] = (-32).dp - } - state(Visibility.VISIBLE) { - this[bottomOffset] = 32.dp - } -} - private enum class Visibility { VISIBLE, GONE @@ -60,27 +49,35 @@ fun JumpToBottom( modifier: Modifier = Modifier ) { // Show Jump to Bottom button - val transition = transition( - definition = definition, - toState = if (enabled) Visibility.VISIBLE else Visibility.GONE + val transition = updateTransition( + if (enabled) Visibility.VISIBLE else Visibility.GONE, + label = "JumpToBottom visibility animation" ) - if (transition[bottomOffset] > 0.dp) { + 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.preferredHeight(18.dp) + modifier = Modifier.height(18.dp), + contentDescription = null ) }, text = { Text(text = stringResource(id = R.string.jumpBottom)) }, onClick = onClicked, - backgroundColor = MaterialTheme.colors.surface, - contentColor = MaterialTheme.colors.primary, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, modifier = modifier - .offset(x = 0.dp, y = -transition[bottomOffset]) - .preferredHeight(36.dp) + .offset(x = 0.dp, y = -bottomOffset) + .height(36.dp) ) } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt index fdd1b9dc16..46cf46b301 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/MessageFormatter.kt @@ -16,14 +16,12 @@ package com.example.compose.jetchat.conversation -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.StringAnnotation -import androidx.compose.ui.text.annotatedString import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle @@ -41,7 +39,7 @@ val symbolPattern by lazy { enum class SymbolAnnotationType { PERSON, LINK } - +typealias StringAnnotation = AnnotatedString.Range<String> // Pair returning styled content and annotation for ClickableText when matching syntax token typealias SymbolAnnotation = Pair<AnnotatedString, StringAnnotation?> @@ -59,7 +57,8 @@ typealias SymbolAnnotation = Pair<AnnotatedString, StringAnnotation?> */ @Composable fun messageFormatter( - text: String + text: String, + primary: Boolean ): AnnotatedString { val tokens = symbolPattern.findAll(text) @@ -68,10 +67,10 @@ fun messageFormatter( var cursorPosition = 0 val codeSnippetBackground = - if (MaterialTheme.colors.isLight) { - Color(0xFFDEDEDE) + if (primary) { + MaterialTheme.colorScheme.secondary } else { - Color(0xFF424242) + MaterialTheme.colorScheme.surface } for (token in tokens) { @@ -79,7 +78,8 @@ fun messageFormatter( val (annotatedString, stringAnnotation) = getSymbolAnnotation( matchResult = token, - colors = MaterialTheme.colors, + colorScheme = MaterialTheme.colorScheme, + primary = primary, codeSnippetBackground = codeSnippetBackground ) append(annotatedString) @@ -108,7 +108,8 @@ fun messageFormatter( */ private fun getSymbolAnnotation( matchResult: MatchResult, - colors: Colors, + colorScheme: ColorScheme, + primary: Boolean, codeSnippetBackground: Color ): SymbolAnnotation { return when (matchResult.value.first()) { @@ -116,7 +117,7 @@ private fun getSymbolAnnotation( AnnotatedString( text = matchResult.value, spanStyle = SpanStyle( - color = colors.primary, + color = if (primary) colorScheme.inversePrimary else colorScheme.primary, fontWeight = FontWeight.Bold ) ), @@ -164,7 +165,7 @@ private fun getSymbolAnnotation( AnnotatedString( text = matchResult.value, spanStyle = SpanStyle( - color = colors.primary + color = if (primary) colorScheme.inversePrimary else colorScheme.primary ) ), StringAnnotation( 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 index 6b4f3ba4d1..cb28308976 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/UserInput.kt @@ -16,73 +16,91 @@ 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.ScrollState -import androidx.compose.foundation.ScrollableRow +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.preferredHeight -import androidx.compose.foundation.layout.preferredSize +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.material.AmbientContentAlpha -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.AmbientTextStyle -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton +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.Providers +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.onCommit import androidx.compose.runtime.remember -import androidx.compose.runtime.savedinstancestate.savedInstanceState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusState -import androidx.compose.ui.focus.focusModifier import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.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.SoftwareKeyboardController import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -93,8 +111,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R -import com.example.compose.jetchat.theme.compositedOnSurface -import com.example.compose.jetchat.theme.elevatedSurface +import kotlin.math.absoluteValue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay enum class InputSelector { NONE, @@ -113,64 +133,74 @@ enum class EmojiStickerSelector { @Preview @Composable fun UserInputPreview() { - UserInput(onMessageSent = {}, scrollState = rememberScrollState()) + UserInput(onMessageSent = {}) } @OptIn(ExperimentalFoundationApi::class) @Composable fun UserInput( onMessageSent: (String) -> Unit, - scrollState: ScrollState, modifier: Modifier = Modifier, + resetScroll: () -> Unit = {}, ) { - var currentInputSelector by savedInstanceState { InputSelector.NONE } + var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) } val dismissKeyboard = { currentInputSelector = InputSelector.NONE } // Intercept back navigation if there's a InputSelector visible if (currentInputSelector != InputSelector.NONE) { - BackPressHandler(onBackPressed = dismissKeyboard) + BackHandler(onBack = dismissKeyboard) } - var textState by remember { mutableStateOf(TextFieldValue()) } + var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } // Used to decide if the keyboard should be shown var textFieldFocusState by remember { mutableStateOf(false) } - Column(modifier) { - Divider() - UserInputText( - textFieldValue = textState, - onTextChanged = { textState = it }, - // Only show the keyboard if there's no input selector and text field has focus - keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState, - // Close extended selector if text field receives focus - onTextFieldFocused = { focused -> - if (focused) { - currentInputSelector = InputSelector.NONE - scrollState.smoothScrollTo(0f) - } - textFieldFocusState = focused - }, - focusState = textFieldFocusState - ) - UserInputSelector( - onSelectorChange = { currentInputSelector = it }, - sendMessageEnabled = textState.text.isNotBlank(), - onMessageSent = { - onMessageSent(textState.text) - // Reset text field and close keyboard - textState = TextFieldValue() - // Move scroll to bottom - scrollState.smoothScrollTo(0f) - dismissKeyboard() - }, - currentInputSelector = currentInputSelector - ) - SelectorExpanded( - onCloseRequested = dismissKeyboard, - onTextAdded = { textState = textState.addText(it) }, - currentSelector = currentInputSelector - ) + 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 + ) + } } } @@ -199,58 +229,54 @@ private fun SelectorExpanded( // 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. - onCommit { + SideEffect { if (currentSelector == InputSelector.EMOJI) { focusRequester.requestFocus() } } - val selectorExpandedColor = getSelectorExpandedColor() - Surface(color = selectorExpandedColor, elevation = 3.dp) { + Surface(tonalElevation = 8.dp) { when (currentSelector) { InputSelector.EMOJI -> EmojiSelector(onTextAdded, focusRequester) InputSelector.DM -> NotAvailablePopup(onCloseRequested) InputSelector.PICTURE -> FunctionalityNotAvailablePanel() InputSelector.MAP -> FunctionalityNotAvailablePanel() InputSelector.PHONE -> FunctionalityNotAvailablePanel() - else -> { throw NotImplementedError() } + else -> { + throw NotImplementedError() + } } } } -@OptIn(ExperimentalAnimationApi::class) @Composable fun FunctionalityNotAvailablePanel() { - AnimatedVisibility(visible = true, initiallyVisible = false, enter = fadeIn()) { + AnimatedVisibility( + visibleState = remember { MutableTransitionState(false).apply { targetState = true } }, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut() + ) { Column( - modifier = Modifier.preferredHeight(320.dp).fillMaxWidth(), + modifier = Modifier + .height(320.dp) + .fillMaxWidth(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(id = R.string.not_available), - style = MaterialTheme.typography.subtitle1 + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(id = R.string.not_available_subtitle), + modifier = Modifier.paddingFrom(FirstBaseline, before = 32.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.not_available_subtitle), - modifier = Modifier.paddingFrom(FirstBaseline, before = 32.dp), - style = MaterialTheme.typography.body2 - ) - } } } } -@Composable -fun getSelectorExpandedColor(): Color { - return if (MaterialTheme.colors.isLight) { - MaterialTheme.colors.compositedOnSurface(0.04f) - } else { - MaterialTheme.colors.elevatedSurface(8.dp) - } -} - @Composable private fun UserInputSelector( onSelectorChange: (InputSelector) -> Unit, @@ -261,9 +287,9 @@ private fun UserInputSelector( ) { Row( modifier = modifier - .preferredHeight(56.dp) + .height(72.dp) .wrapContentHeight() - .padding(horizontal = 4.dp), + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically ) { InputSelectorButton( @@ -300,31 +326,27 @@ private fun UserInputSelector( val border = if (!sendMessageEnabled) { BorderStroke( width = 1.dp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) ) } else { null } Spacer(modifier = Modifier.weight(1f)) - val disabledContentColor = - MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) + val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) val buttonColors = ButtonDefaults.buttonColors( - disabledBackgroundColor = MaterialTheme.colors.surface, + disabledContainerColor = Color.Transparent, disabledContentColor = disabledContentColor ) // Send button Button( - modifier = Modifier - .padding(horizontal = 16.dp) - .preferredHeight(36.dp), + modifier = Modifier.height(36.dp), enabled = sendMessageEnabled, onClick = onMessageSent, colors = buttonColors, border = border, - // TODO: Workaround for https://issuetracker.google.com/158830170 contentPadding = PaddingValues(0.dp) ) { Text( @@ -340,20 +362,34 @@ private fun InputSelectorButton( onClick: () -> Unit, icon: ImageVector, description: String, - selected: Boolean + 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.semantics { contentDescription = description } + modifier = modifier.then(backgroundModifier) ) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - val tint = if (selected) MaterialTheme.colors.primary else AmbientContentColor.current - Icon( - icon, - tint = tint, - modifier = Modifier.padding(12.dp).preferredSize(20.dp) - ) + 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 + ) } } @@ -365,6 +401,7 @@ private fun NotAvailablePopup(onDismissed: () -> Unit) { val KeyboardShownKey = SemanticsPropertyKey<Boolean>("KeyboardShownKey") var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey +@OptIn(ExperimentalAnimationApi::class) @ExperimentalFoundationApi @Composable private fun UserInputText( @@ -373,70 +410,174 @@ private fun UserInputText( textFieldValue: TextFieldValue, keyboardShown: Boolean, onTextFieldFocused: (Boolean) -> Unit, + onMessageSent: (String) -> Unit, focusState: Boolean ) { - // Grab a reference to the keyboard controller whenever text input starts - var keyboardController by remember { mutableStateOf<SoftwareKeyboardController?>(null) } - - // Show or hide the keyboard - onCommit(keyboardController, keyboardShown) { // Guard side-effects against failed commits - keyboardController?.let { - if (keyboardShown) it.showSoftwareKeyboard() else it.hideSoftwareKeyboard() - } - } - + val swipeOffset = remember { mutableStateOf(0f) } + var isRecordingMessage by remember { mutableStateOf(false) } val a11ylabel = stringResource(id = R.string.textfield_desc) Row( modifier = Modifier .fillMaxWidth() - .preferredHeight(48.dp) - .semantics { - contentDescription = a11ylabel - keyboardShownProperty = keyboardShown - }, + .height(64.dp), horizontalArrangement = Arrangement.End ) { - Surface { - Box( - modifier = Modifier.preferredHeight(48.dp).weight(1f).align(Alignment.Bottom) - ) { - var lastFocusState by remember { mutableStateOf(FocusState.Inactive) } - BasicTextField( - value = textFieldValue, - onValueChange = { onTextChanged(it) }, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp) - .align(Alignment.CenterStart) - .onFocusChanged { state -> - if (lastFocusState != state) { - onTextFieldFocused(state == FocusState.Active) - } - lastFocusState = state - }, - keyboardOptions = KeyboardOptions( - keyboardType = keyboardType, - imeAction = ImeAction.Send - ), - onTextInputStarted = { keyboardController = it }, - maxLines = 1, - cursorColor = AmbientContentColor.current, - textStyle = AmbientTextStyle.current.copy(color = AmbientContentColor.current) - ) - - val disableContentColor = - MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) - if (textFieldValue.text.isEmpty() && !focusState) { - Text( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp), - text = stringResource(id = R.string.textfield_hint), - style = MaterialTheme.typography.body1.copy(color = disableContentColor) + 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 + ) + } } } @@ -452,10 +593,14 @@ fun EmojiSelector( modifier = Modifier .focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed // Make the emoji selector focusable so it can steal focus from TextField - .focusModifier() + .focusTarget() .semantics { contentDescription = a11yLabel } ) { - Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { ExtendedSelectorInnerButton( text = stringResource(id = R.string.emojis_label), onClick = { selected = EmojiStickerSelector.EMOJI }, @@ -469,7 +614,7 @@ fun EmojiSelector( modifier = Modifier.weight(1f) ) } - ScrollableRow { + Row(modifier = Modifier.verticalScroll(rememberScrollState())) { EmojiTable(onTextAdded, modifier = Modifier.padding(8.dp)) } } @@ -486,25 +631,23 @@ fun ExtendedSelectorInnerButton( modifier: Modifier = Modifier ) { val colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.08f), - disabledBackgroundColor = getSelectorExpandedColor(), // Same as background - contentColor = MaterialTheme.colors.onSurface, - disabledContentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.74f) + containerColor = if (selected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else Color.Transparent, + disabledContainerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f) ) TextButton( onClick = onClick, modifier = modifier - .padding(horizontal = 8.dp, vertical = 8.dp) - .preferredHeight(30.dp), - shape = MaterialTheme.shapes.medium, - enabled = selected, + .padding(8.dp) + .height(36.dp), colors = colors, - // TODO: Workaround for https://issuetracker.google.com//158830170 contentPadding = PaddingValues(0.dp) ) { Text( text = text, - style = MaterialTheme.typography.subtitle2 + style = MaterialTheme.typography.titleSmall ) } } @@ -528,7 +671,7 @@ fun EmojiTable( .sizeIn(minWidth = 42.dp, minHeight = 42.dp) .padding(8.dp), text = emoji, - style = AmbientTextStyle.current.copy( + style = LocalTextStyle.current.copy( fontSize = 18.sp, textAlign = TextAlign.Center ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt index 263b902310..102930dd64 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/data/FakeData.kt @@ -19,47 +19,69 @@ package com.example.compose.jetchat.data import com.example.compose.jetchat.R import com.example.compose.jetchat.conversation.ConversationUiState import com.example.compose.jetchat.conversation.Message +import com.example.compose.jetchat.data.EMOJIS.EMOJI_CLOUDS +import com.example.compose.jetchat.data.EMOJIS.EMOJI_FLAMINGO +import com.example.compose.jetchat.data.EMOJIS.EMOJI_MELTING +import com.example.compose.jetchat.data.EMOJIS.EMOJI_PINK_HEART +import com.example.compose.jetchat.data.EMOJIS.EMOJI_POINTS import com.example.compose.jetchat.profile.ProfileScreenState -private val initialMessages = listOf( +val initialMessages = listOf( Message( "me", - "Compose newbie: I’ve scourged the internet for tutorials about async data loading " + - "but haven’t found any good ones. What’s the recommended way to load async " + - "data and emit composable widgets?", - "8:03 PM" + "Check it out!", + "8:07 PM" ), Message( - "John Glenn", - "Compose newbie as well, have you looked at the JetNews sample? Most blog posts end up " + - "out of date pretty fast but this sample is always up to date and deals with async " + - "data loading (it's faked but the same idea applies) \uD83D\uDC49" + - "https://github.com/android/compose-samples/tree/master/JetNews", - "8:04 PM" + "me", + "Thank you!$EMOJI_PINK_HEART", + "8:06 PM", + R.drawable.sticker ), Message( "Taylor Brooks", - "@aliconors Take a look at the `Flow.collectAsState()` APIs", + "You can use all the same stuff", "8:05 PM" ), Message( "Taylor Brooks", - "You can use all the same stuff", + "@aliconors Take a look at the `Flow.collectAsStateWithLifecycle()` APIs", "8:05 PM" ), Message( - "me", - "Thank you!", - "8:06 PM", - R.drawable.sticker + "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", - "Check it out!", - "8:07 PM" - ) + "Compose newbie: I’ve scourged the internet for tutorials about async data " + + "loading but haven’t found any good ones $EMOJI_MELTING $EMOJI_CLOUDS. " + + "What’s the recommended way to load async data and emit composable widgets?", + "8:03 PM" + ), + Message( + "Shangeeth Sivan", + "Does anyone know about Glance Widgets its the new way to build widgets in Android!", + "8:08 PM" + ), + Message( + "Taylor Brooks", + "Wow! I never knew about Glance Widgets when was this added to the android ecosystem", + "8:10 PM" + ), + Message( + "John Glenn", + "Yeah its seems to be pretty new!", + "8:12 PM" + ), ) +val unreadMessages = initialMessages.filter { it.author != "me" } + val exampleUiState = ConversationUiState( initialMessages = initialMessages, channelName = "#composers", @@ -95,3 +117,20 @@ val meProfile = ProfileScreenState( 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/Profile.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt index d4a88f36b9..0bc8c651ae 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/Profile.kt @@ -18,105 +18,111 @@ package com.example.compose.jetchat.profile import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredHeightIn +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.FloatingActionButton -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +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.material.icons.outlined.MoreVert +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.Providers +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.layout.WithConstraints -import androidx.compose.ui.platform.AmbientDensity -import androidx.compose.ui.res.imageResource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R import com.example.compose.jetchat.components.AnimatingFabContent -import com.example.compose.jetchat.components.JetchatAppBar import com.example.compose.jetchat.components.baselineHeight +import com.example.compose.jetchat.data.colleagueProfile import com.example.compose.jetchat.data.meProfile import com.example.compose.jetchat.theme.JetchatTheme -import dev.chrisbanes.accompanist.insets.navigationBarsPadding -import dev.chrisbanes.accompanist.insets.statusBarsPadding +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -fun ProfileScreen(userData: ProfileScreenState, onNavIconPressed: () -> Unit = { }) { +fun ProfileScreen( + userData: ProfileScreenState, + nestedScrollInteropConnection: NestedScrollConnection = rememberNestedScrollInteropConnection() +) { + var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } + if (functionalityNotAvailablePopupShown) { + FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } + } val scrollState = rememberScrollState() - Column(modifier = Modifier.fillMaxSize()) { - JetchatAppBar( - // Use statusBarsPadding() to move the app bar content below the status bar - modifier = Modifier.fillMaxWidth().statusBarsPadding(), - onNavIconPressed = onNavIconPressed, - title = { }, - actions = { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - // More icon - Icon( - imageVector = Icons.Outlined.MoreVert, - modifier = Modifier - .clickable(onClick = {}) // TODO: Show not implemented dialog. - .padding(horizontal = 12.dp, vertical = 16.dp) - .preferredHeight(24.dp) - ) - } - } - ) - WithConstraints { - Box(modifier = Modifier.weight(1f)) { - Surface { - ScrollableColumn( - modifier = Modifier.fillMaxSize(), - scrollState = scrollState - ) { - ProfileHeader( - scrollState, - userData - ) - UserInfoFields(userData, maxHeight) - } - } - ProfileFab( - extended = scrollState.value == 0f, - userIsMe = userData.isMe(), - modifier = Modifier.align(Alignment.BottomEnd) + 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.preferredHeight(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) NameAndPosition(userData) @@ -132,7 +138,7 @@ private fun UserInfoFields(userData: ProfileScreenState, containerHeight: Dp) { // 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.preferredHeight((containerHeight - 320.dp).coerceAtLeast(0.dp))) + Spacer(Modifier.height((containerHeight - 320.dp).coerceAtLeast(0.dp))) } } @@ -147,7 +153,9 @@ private fun NameAndPosition( ) Position( userData, - modifier = Modifier.padding(bottom = 20.dp).baselineHeight(24.dp) + modifier = Modifier + .padding(bottom = 20.dp) + .baselineHeight(24.dp) ) } } @@ -157,41 +165,44 @@ private fun Name(userData: ProfileScreenState, modifier: Modifier = Modifier) { Text( text = userData.name, modifier = modifier, - style = MaterialTheme.typography.h5 + style = MaterialTheme.typography.headlineSmall ) } @Composable private fun Position(userData: ProfileScreenState, modifier: Modifier = Modifier) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = userData.position, - modifier = modifier, - style = MaterialTheme.typography.body1 - ) - } + Text( + text = userData.position, + modifier = modifier, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } @Composable private fun ProfileHeader( scrollState: ScrollState, - data: ProfileScreenState + data: ProfileScreenState, + containerHeight: Dp ) { val offset = (scrollState.value / 2) - val offsetDp = with(AmbientDensity.current) { offset.toDp() } + val offsetDp = with(LocalDensity.current) { offset.toDp() } data.photo?.let { - val asset = imageResource(id = it) - val ratioAsset = (asset.width / asset.height.toFloat()).coerceAtLeast(1f) - - // TODO: Fix landscape Image( modifier = Modifier - .aspectRatio(ratioAsset) - .preferredHeightIn(max = 320.dp) - .padding(top = offsetDp), - bitmap = asset, - contentScale = ContentScale.FillWidth + .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 ) } } @@ -200,17 +211,16 @@ private fun ProfileHeader( fun ProfileProperty(label: String, value: String, isLink: Boolean = false) { Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) { Divider() - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = label, - modifier = Modifier.baselineHeight(24.dp), - style = MaterialTheme.typography.caption - ) - } + Text( + text = label, + modifier = Modifier.baselineHeight(24.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) val style = if (isLink) { - MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.primary) + MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary) } else { - MaterialTheme.typography.body1 + MaterialTheme.typography.bodyLarge } Text( text = value, @@ -226,23 +236,29 @@ fun ProfileError() { } @Composable -fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifier) { - +fun ProfileFab( + extended: Boolean, + userIsMe: Boolean, + modifier: Modifier = Modifier, + onFabClicked: () -> Unit = { } +) { key(userIsMe) { // Prevent multiple invocations to execute during composition FloatingActionButton( - onClick = { /* TODO */ }, + onClick = onFabClicked, modifier = modifier .padding(16.dp) .navigationBarsPadding() - .preferredHeight(48.dp) + .height(48.dp) .widthIn(min = 48.dp), - backgroundColor = MaterialTheme.colors.primary, - contentColor = MaterialTheme.colors.onPrimary + containerColor = MaterialTheme.colorScheme.tertiaryContainer ) { AnimatingFabContent( icon = { Icon( - imageVector = if (userIsMe) Icons.Outlined.Create else Icons.Outlined.Chat + imageVector = if (userIsMe) Icons.Outlined.Create else Icons.Outlined.Chat, + contentDescription = stringResource( + if (userIsMe) R.string.edit_profile else R.string.message + ) ) }, text = { @@ -253,20 +269,35 @@ fun ProfileFab(extended: Boolean, userIsMe: Boolean, modifier: Modifier = Modifi ) }, extended = extended - ) } } } -@Preview +@Preview(widthDp = 640, heightDp = 360) +@Composable +fun ConvPreviewLandscapeMeDefault() { + JetchatTheme { + ProfileScreen(meProfile) + } +} + +@Preview(widthDp = 360, heightDp = 480) @Composable -fun ConvPreview480MeDefault() { +fun ConvPreviewPortraitMeDefault() { JetchatTheme { ProfileScreen(meProfile) } } +@Preview(widthDp = 360, heightDp = 480) +@Composable +fun ConvPreviewPortraitOtherDefault() { + JetchatTheme { + ProfileScreen(colleagueProfile) + } +} + @Preview @Composable fun ProfileFabPreview() { diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt index 8c9dd572c4..971a008772 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileFragment.kt @@ -21,17 +21,34 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.runtime.Providers +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 -import dev.chrisbanes.accompanist.insets.AmbientWindowInsets -import dev.chrisbanes.accompanist.insets.ViewWindowInsetObserver class ProfileFragment : Fragment() { @@ -45,38 +62,63 @@ class ProfileFragment : Fragment() { viewModel.setUserId(userId) } + @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View = ComposeView(inflater.context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) + ): View { + val rootView: View = inflater.inflate(R.layout.fragment_profile, container, false) - // Create a ViewWindowInsetObserver using this view, and call start() to - // start listening now. The WindowInsets instance is returned, allowing us to - // provide it to AmbientWindowInsets in our content below. - val windowInsets = ViewWindowInsetObserver(this).start() + rootView.findViewById<ComposeView>(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) + ) + } + ) + } + } + } - setContent { - val userData by viewModel.userData.observeAsState() + rootView.findViewById<ComposeView>(R.id.profile_compose_view).apply { + setContent { + val userData by viewModel.userData.observeAsState() + val nestedScrollInteropConnection = rememberNestedScrollInteropConnection() - Providers(AmbientWindowInsets provides windowInsets) { JetchatTheme { if (userData == null) { ProfileError() } else { ProfileScreen( userData = userData!!, - onNavIconPressed = { - activityViewModel.openDrawer() - } + nestedScrollInteropConnection = nestedScrollInteropConnection ) } } } } + return rootView } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt index 792b5db25a..bbb7b8d823 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/profile/ProfileViewModel.kt @@ -32,7 +32,12 @@ class ProfileViewModel : ViewModel() { if (newUserId != userId) { userId = newUserId ?: meProfile.userId } - _userData.value = if (userId == meProfile.userId) meProfile else colleagueProfile + // Workaround for simplicity + _userData.value = if (userId == meProfile.userId || userId == meProfile.displayName) { + meProfile + } else { + colleagueProfile + } } private val _userData = MutableLiveData<ProfileScreenState>() diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt index 98cbe18a4a..35d5181f7d 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Color.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,29 +16,45 @@ package com.example.compose.jetchat.theme -import androidx.compose.material.AmbientElevationOverlay -import androidx.compose.material.Colors -import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.unit.Dp -/** - * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the - * given [alpha]. Useful for situations where semi-transparent colors are undesirable. - */ -@Composable -fun Colors.compositedOnSurface(alpha: Float): Color { - return onSurface.copy(alpha = alpha).compositeOver(surface) -} +val Blue10 = Color(0xFF000F5E) +val Blue20 = Color(0xFF001E92) +val Blue30 = Color(0xFF002ECC) +val Blue40 = Color(0xFF1546F6) +val Blue80 = Color(0xFFB8C3FF) +val Blue90 = Color(0xFFDDE1FF) -/** - * Calculates the color of an elevated `surface` in dark mode. Returns `surface` in light mode. - */ -@Composable -fun Colors.elevatedSurface(elevation: Dp): Color { - return AmbientElevationOverlay.current?.apply( - color = this.surface, - elevation = elevation - ) ?: this.surface -} +val DarkBlue10 = Color(0xFF00036B) +val DarkBlue20 = Color(0xFF000BA6) +val DarkBlue30 = Color(0xFF1026D3) +val DarkBlue40 = Color(0xFF3648EA) +val DarkBlue80 = Color(0xFFBBC2FF) +val DarkBlue90 = Color(0xFFDEE0FF) + +val Yellow10 = Color(0xFF261900) +val Yellow20 = Color(0xFF402D00) +val Yellow30 = Color(0xFF5C4200) +val Yellow40 = Color(0xFF7A5900) +val Yellow80 = Color(0xFFFABD1B) +val Yellow90 = Color(0xFFFFDE9C) + +val Red10 = Color(0xFF410001) +val Red20 = Color(0xFF680003) +val Red30 = Color(0xFF930006) +val Red40 = Color(0xFFBA1B1B) +val Red80 = Color(0xFFFFB4A9) +val Red90 = Color(0xFFFFDAD4) + +val Grey10 = Color(0xFF191C1D) +val Grey20 = Color(0xFF2D3132) +val Grey80 = Color(0xFFC4C7C7) +val Grey90 = Color(0xFFE0E3E3) +val Grey95 = Color(0xFFEFF1F1) +val Grey99 = Color(0xFFFBFDFD) + +val BlueGrey30 = Color(0xFF45464F) +val BlueGrey50 = Color(0xFF767680) +val BlueGrey60 = Color(0xFF90909A) +val BlueGrey80 = Color(0xFFC6C5D0) +val BlueGrey90 = Color(0xFFE2E1EC) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Shapes.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Shapes.kt deleted file mode 100644 index e7b3d589c9..0000000000 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Shapes.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -val JetchatShapes = Shapes( - small = RoundedCornerShape(50), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(0.dp) -) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt index 074b01cf4f..8fce79255d 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Themes.kt @@ -16,64 +16,98 @@ package com.example.compose.jetchat.theme +import android.annotation.SuppressLint +import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext -private val Yellow400 = Color(0xFFF6E547) -private val Yellow600 = Color(0xFFF5CF1B) -private val Yellow700 = Color(0xFFF3B711) -private val Yellow800 = Color(0xFFF29F05) - -private val Blue200 = Color(0xFF9DA3FA) -private val Blue400 = Color(0xFF4860F7) -private val Blue500 = Color(0xFF0540F2) -private val Blue800 = Color(0xFF001CCF) - -private val Red300 = Color(0xFFEA6D7E) -private val Red800 = Color(0xFFD00036) - -private val JetchatDarkPalette = darkColors( - primary = Blue200, - primaryVariant = Blue400, - onPrimary = Color.Black, - secondary = Yellow400, - onSecondary = Color.Black, - onSurface = Color.White, - onBackground = Color.White, - error = Red300, - onError = Color.Black +val JetchatDarkColorScheme = darkColorScheme( + primary = Blue80, + onPrimary = Blue20, + primaryContainer = Blue30, + onPrimaryContainer = Blue90, + inversePrimary = Blue40, + secondary = DarkBlue80, + onSecondary = DarkBlue20, + secondaryContainer = DarkBlue30, + onSecondaryContainer = DarkBlue90, + tertiary = Yellow80, + onTertiary = Yellow20, + tertiaryContainer = Yellow30, + onTertiaryContainer = Yellow90, + error = Red80, + onError = Red20, + errorContainer = Red30, + onErrorContainer = Red90, + background = Grey10, + onBackground = Grey90, + surface = Grey10, + onSurface = Grey80, + inverseSurface = Grey90, + inverseOnSurface = Grey20, + surfaceVariant = BlueGrey30, + onSurfaceVariant = BlueGrey80, + outline = BlueGrey60 ) -private val JetchatLightPalette = lightColors( - primary = Blue500, - primaryVariant = Blue800, +val JetchatLightColorScheme = lightColorScheme( + primary = Blue40, onPrimary = Color.White, - secondary = Yellow700, - secondaryVariant = Yellow800, - onSecondary = Color.Black, - onSurface = Color.Black, - onBackground = Color.Black, - error = Red800, - onError = Color.White + primaryContainer = Blue90, + onPrimaryContainer = Blue10, + inversePrimary = Blue80, + secondary = DarkBlue40, + onSecondary = Color.White, + secondaryContainer = DarkBlue90, + onSecondaryContainer = DarkBlue10, + tertiary = Yellow40, + onTertiary = Color.White, + tertiaryContainer = Yellow90, + onTertiaryContainer = Yellow10, + error = Red40, + onError = Color.White, + errorContainer = Red90, + onErrorContainer = Red10, + background = Grey99, + onBackground = Grey10, + surface = Grey99, + onSurface = Grey10, + inverseSurface = Grey20, + inverseOnSurface = Grey95, + surfaceVariant = BlueGrey90, + onSurfaceVariant = BlueGrey30, + outline = BlueGrey50, ) +@SuppressLint("NewApi") @Composable fun JetchatTheme( isDarkTheme: Boolean = isSystemInDarkTheme(), - colors: Colors? = null, + isDynamicColor: Boolean = true, content: @Composable () -> Unit ) { - val myColors = colors ?: if (isDarkTheme) JetchatDarkPalette else JetchatLightPalette + val dynamicColor = isDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val myColorScheme = when { + dynamicColor && isDarkTheme -> { + dynamicDarkColorScheme(LocalContext.current) + } + dynamicColor && !isDarkTheme -> { + dynamicLightColorScheme(LocalContext.current) + } + isDarkTheme -> JetchatDarkColorScheme + else -> JetchatLightColorScheme + } MaterialTheme( - colors = myColors, - content = content, + colorScheme = myColorScheme, typography = JetchatTypography, - shapes = JetchatShapes + content = content ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt index 12ede6b13d..035d179988 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/theme/Typography.kt @@ -16,94 +16,148 @@ package com.example.compose.jetchat.theme -import androidx.compose.material.Typography +import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.font.font -import androidx.compose.ui.text.font.fontFamily +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont import androidx.compose.ui.unit.sp import com.example.compose.jetchat.R -private val MontserratFontFamily = fontFamily( - font(R.font.montserrat_regular), - font(R.font.montserrat_light, FontWeight.Light), - font(R.font.montserrat_semibold, FontWeight.SemiBold) +val provider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs ) -private val KarlaFontFamily = fontFamily( - font(R.font.karla_regular), - font(R.font.karla_bold, FontWeight.Bold) +val MontserratFont = GoogleFont(name = "Montserrat") + +val KarlaFont = GoogleFont(name = "Karla") + +val MontserratFontFamily = FontFamily( + Font(googleFont = MontserratFont, fontProvider = provider), + Font(resId = R.font.montserrat_regular), + Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Light), + Font(resId = R.font.montserrat_light, weight = FontWeight.Light), + Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.Medium), + Font(resId = R.font.montserrat_medium, weight = FontWeight.Medium), + Font(googleFont = MontserratFont, fontProvider = provider, weight = FontWeight.SemiBold), + Font(resId = R.font.montserrat_semibold, weight = FontWeight.SemiBold), +) + +val KarlaFontFamily = FontFamily( + Font(googleFont = KarlaFont, fontProvider = provider), + Font(resId = R.font.karla_regular), + Font(googleFont = KarlaFont, fontProvider = provider, weight = FontWeight.Bold), + Font(resId = R.font.karla_bold, weight = FontWeight.Bold), ) val JetchatTypography = Typography( - defaultFontFamily = MontserratFontFamily, - h1 = TextStyle( + displayLarge = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.Light, - fontSize = 96.sp, - letterSpacing = (-1.5).sp + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = 0.sp ), - h2 = TextStyle( + displayMedium = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.Light, - fontSize = 60.sp, - letterSpacing = (-0.5).sp + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp ), - h3 = TextStyle( + displaySmall = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.Normal, - fontSize = 48.sp, + fontSize = 36.sp, + lineHeight = 44.sp, letterSpacing = 0.sp ), - h4 = TextStyle( + headlineLarge = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.SemiBold, - fontSize = 30.sp, + fontSize = 32.sp, + lineHeight = 40.sp, letterSpacing = 0.sp ), - h5 = TextStyle( + 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 ), - h6 = TextStyle( + titleLarge = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.SemiBold, - fontSize = 20.sp, + fontSize = 22.sp, + lineHeight = 28.sp, letterSpacing = 0.sp ), - subtitle1 = TextStyle( + titleMedium = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, - letterSpacing = 0.sp + lineHeight = 24.sp, + letterSpacing = 0.15.sp ), - subtitle2 = TextStyle( + titleSmall = TextStyle( fontFamily = KarlaFontFamily, fontWeight = FontWeight.Bold, fontSize = 14.sp, + lineHeight = 20.sp, letterSpacing = 0.1.sp ), - body1 = TextStyle( + bodyLarge = TextStyle( fontFamily = KarlaFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, - letterSpacing = 0.sp, - lineHeight = 24.sp + lineHeight = 24.sp, + letterSpacing = 0.15.sp ), - body2 = TextStyle( + bodyMedium = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, + lineHeight = 20.sp, letterSpacing = 0.25.sp ), - button = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - letterSpacing = 1.25.sp - ), - caption = TextStyle( + bodySmall = TextStyle( fontFamily = KarlaFontFamily, fontWeight = FontWeight.Bold, fontSize = 12.sp, - letterSpacing = 0.15.sp + lineHeight = 16.sp, + letterSpacing = 0.4.sp ), - overline = TextStyle( + labelLarge = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = MontserratFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, - letterSpacing = 1.sp + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = MontserratFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp ) ) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/JetChatWidget.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/JetChatWidget.kt new file mode 100644 index 0000000000..437309351f --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/JetChatWidget.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.provideContent +import com.example.compose.jetchat.data.unreadMessages +import com.example.compose.jetchat.widget.composables.MessagesWidget + +class JetChatWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + GlanceTheme { + MessagesWidget(unreadMessages.toList()) + } + } + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/WidgetReceiver.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/WidgetReceiver.kt new file mode 100644 index 0000000000..ad67c3d170 --- /dev/null +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/WidgetReceiver.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class WidgetReceiver : GlanceAppWidgetReceiver() { + + override val glanceAppWidget: GlanceAppWidget + get() = JetChatWidget() +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/composables/MessagesWidget.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/widget/composables/MessagesWidget.kt new file mode 100644 index 0000000000..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<Message>) { + 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<NavActivity>()).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/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 @@ +<!-- + ~ 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 + ~ + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="32dp" + android:height="32dp" + android:viewportWidth="32" + android:viewportHeight="32"> + <path + android:pathData="M15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H27.2841C29.8886,6.6964 31.9999,8.8077 31.9999,11.4122C31.9999,14.0167 29.8886,16.128 27.2841,16.128H20.4126C17.8081,16.128 15.6968,14.0167 15.6968,11.4122Z" + android:fillColor="@color/yellow_logo"/> + <path + android:pathData="M16.4379,20.8438C16.4379,20.8439 16.4379,20.8439 16.4379,20.844C16.4379,23.4485 14.3266,25.5598 11.7221,25.5598H6.2653C6.2653,25.5597 6.2653,25.5597 6.2653,25.5596C6.2653,22.9551 8.3766,20.8438 10.981,20.8438H16.4379Z" + android:fillColor="@color/yellow_logo" + android:fillType="evenOdd"/> + <path + android:pathData="M19.2,25.5596C19.2,22.9551 21.3113,20.8438 23.9157,20.8438C26.5202,20.8438 28.6315,22.9551 28.6315,25.5596C28.6315,28.1641 26.5202,30.2754 23.9157,30.2754C21.3113,30.2754 19.2,28.1641 19.2,25.5596Z" + android:fillColor="@color/yellow_logo"/> +</vector> 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 @@ +<!-- + ~ 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 + ~ + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="32dp" + android:height="32dp" + android:viewportWidth="32" + android:viewportHeight="32"> + <path + android:pathData="M25.5326,6.6964C25.5326,6.6965 25.5326,6.6965 25.5326,6.6966C25.5326,9.3011 23.4212,11.4124 20.8168,11.4124H15.6968C15.6968,11.4123 15.6968,11.4123 15.6968,11.4122C15.6968,8.8077 17.8081,6.6964 20.4126,6.6964H25.5326Z" + android:fillColor="@color/blue_logo" + android:fillType="evenOdd"/> + <path + android:pathData="M0,20.8438C0,18.2393 2.1113,16.128 4.7158,16.128H11.7221C14.3266,16.128 16.4379,18.2393 16.4379,20.8438C16.4379,23.4482 14.3266,25.5596 11.7221,25.5596H4.7158C2.1113,25.5596 0,23.4482 0,20.8438Z" + android:fillColor="@color/blue_logo"/> + <path + android:pathData="M3.5032,6.6964C3.5032,4.092 5.6145,1.9806 8.219,1.9806C10.8234,1.9806 12.9348,4.092 12.9348,6.6964C12.9348,9.3009 10.8234,11.4122 8.219,11.4122C5.6145,11.4122 3.5032,9.3009 3.5032,6.6964Z" + android:fillColor="@color/blue_logo"/> +</vector> diff --git a/Jetchat/app/src/main/res/drawable/ic_launcher_foreground.xml b/Jetchat/app/src/main/res/drawable/ic_launcher_foreground.xml index d5be3e12b8..bdc8735ded 100644 --- a/Jetchat/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/Jetchat/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -16,10 +16,10 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" - android:width="110dp" - android:height="110dp" - android:viewportWidth="110" - android:viewportHeight="110"> + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> <path android:pathData="M65,43.524L71.5,43.524L74.859,46.859L85.281,57.281L106.125,78.125L77.776,106.473L36.089,64.786L43.176,57.699L48.5,55.999L41,47.5L45.5,43.524L49.5,39.499L56,45.999L58,43.999L65,43.524Z"> <aapt:attr name="android:fillColor"> 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 @@ +<!-- + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + + <path + android:fillColor="#ffffff" + android:pathData="M54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H69.105C72.361,43.371 75,46.01 75,49.266V49.266C75,52.521 72.361,55.161 69.105,55.161H60.516C57.26,55.161 54.621,52.521 54.621,49.266V49.266Z" /> + <path + android:fillColor="#ffffff" + android:fillType="evenOdd" + android:pathData="M66.916,43.371C66.916,43.371 66.916,43.371 66.916,43.371C66.916,46.627 64.277,49.266 61.021,49.266H54.621C54.621,49.266 54.621,49.266 54.621,49.266C54.621,46.01 57.26,43.371 60.516,43.371H66.916Z" /> + <path + android:fillColor="#ffffff" + android:pathData="M35,61.055C35,57.799 37.639,55.16 40.895,55.16H49.653C52.908,55.16 55.547,57.799 55.547,61.055V61.055C55.547,64.311 52.908,66.95 49.653,66.95H40.895C37.639,66.95 35,64.311 35,61.055V61.055Z" /> + <path + android:fillColor="#ffffff" + android:fillType="evenOdd" + android:pathData="M55.547,61.055C55.547,61.055 55.547,61.055 55.547,61.055C55.547,64.311 52.908,66.95 49.653,66.95H42.832C42.832,66.95 42.832,66.95 42.832,66.949C42.832,63.694 45.471,61.055 48.726,61.055H55.547Z" /> + <path + android:fillColor="#ffffff" + android:pathData="M59,66.949C59,63.694 61.639,61.055 64.895,61.055V61.055C68.15,61.055 70.789,63.694 70.789,66.949V66.949C70.789,70.205 68.15,72.844 64.895,72.844V72.844C61.639,72.844 59,70.205 59,66.949V66.949Z" /> + <path + android:fillColor="#ffffff" + android:pathData="M39.379,43.371C39.379,40.116 42.018,37.477 45.274,37.477V37.477C48.529,37.477 51.168,40.116 51.168,43.371V43.371C51.168,46.627 48.529,49.266 45.274,49.266V49.266C42.018,49.266 39.379,46.627 39.379,43.371V43.371Z" /> +</vector> diff --git a/Jetchat/app/src/main/res/drawable/widget_icon.png b/Jetchat/app/src/main/res/drawable/widget_icon.png new file mode 100644 index 0000000000..70d386ee16 Binary files /dev/null and b/Jetchat/app/src/main/res/drawable/widget_icon.png differ diff --git a/Jetchat/app/src/main/res/layout/content_main.xml b/Jetchat/app/src/main/res/layout/content_main.xml index 61198607ea..31302894b9 100644 --- a/Jetchat/app/src/main/res/layout/content_main.xml +++ b/Jetchat/app/src/main/res/layout/content_main.xml @@ -16,12 +16,19 @@ --> -<androidx.fragment.app.FragmentContainerView +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" - android:id="@+id/nav_host_fragment" - android:name="androidx.navigation.fragment.NavHostFragment" + xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent" - app:defaultNavHost="true" - app:navGraph="@navigation/mobile_navigation" /> + android:layout_height="match_parent"> + + <androidx.fragment.app.FragmentContainerView + android:id="@+id/nav_host_fragment" + android:name="androidx.navigation.fragment.NavHostFragment" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:defaultNavHost="true" + app:navGraph="@navigation/mobile_navigation" + tools:ignore="FragmentTagUsage" /> +</FrameLayout> 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 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ 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. + --> +<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/coordinator_layout" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <com.google.android.material.appbar.AppBarLayout + android:id="@+id/app_bar_layout" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <androidx.compose.ui.platform.ComposeView + android:id="@+id/toolbar_compose_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:layout_scrollFlags="scroll|exitUntilCollapsed" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> + + </com.google.android.material.appbar.AppBarLayout> + + <androidx.compose.ui.platform.ComposeView + android:id="@+id/profile_compose_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> + +</androidx.coordinatorlayout.widget.CoordinatorLayout> \ No newline at end of file diff --git a/Jetchat/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetchat/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 7353dbd1fd..c78bee3b53 100644 --- a/Jetchat/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/Jetchat/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@color/ic_launcher_background"/> - <foreground android:drawable="@drawable/ic_launcher_foreground"/> -</adaptive-icon> \ No newline at end of file + <background android:drawable="@color/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_monochrome" /> +</adaptive-icon> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. +--> +<resources> + <array name="com_google_android_gms_fonts_certs"> + <item>@array/com_google_android_gms_fonts_certs_dev</item> + <item>@array/com_google_android_gms_fonts_certs_prod</item> + </array> + <string-array name="com_google_android_gms_fonts_certs_dev"> + <item> + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + </item> + </string-array> + <string-array name="com_google_android_gms_fonts_certs_prod"> + <item> + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + </item> + </string-array> +</resources> diff --git a/Jetchat/app/src/main/res/values-v23/themes.xml b/Jetchat/app/src/main/res/values-v23/themes.xml index 619594d6f2..0263ad1720 100644 --- a/Jetchat/app/src/main/res/values-v23/themes.xml +++ b/Jetchat/app/src/main/res/values-v23/themes.xml @@ -16,7 +16,7 @@ <resources> - <style name="Platform.Theme.Jetchat" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> + <style name="Platform.Theme.Jetchat" parent="android:Theme.Material.Light.NoActionBar"> <item name="android:statusBarColor">@android:color/transparent</item> <item name="android:windowLightStatusBar">?attr/isLightTheme</item> </style> diff --git a/Jetchat/app/src/main/res/values-v27/themes.xml b/Jetchat/app/src/main/res/values-v27/themes.xml index 40b0d1402e..a5d1c47d6c 100644 --- a/Jetchat/app/src/main/res/values-v27/themes.xml +++ b/Jetchat/app/src/main/res/values-v27/themes.xml @@ -16,7 +16,7 @@ <resources> - <style name="Platform.Theme.Jetchat" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> + <style name="Platform.Theme.Jetchat" parent="android:Theme.Material.Light.NoActionBar"> <item name="android:statusBarColor">@android:color/transparent</item> <item name="android:windowLightStatusBar">?attr/isLightTheme</item> <item name="android:navigationBarColor">@android:color/transparent</item> diff --git a/Jetchat/app/src/main/res/values/strings.xml b/Jetchat/app/src/main/res/values/strings.xml index 7d72a031bd..bc04038a7a 100644 --- a/Jetchat/app/src/main/res/values/strings.xml +++ b/Jetchat/app/src/main/res/values/strings.xml @@ -35,6 +35,7 @@ <string name="now">8:30 PM</string> <string name="members">%d members</string> <string name="textfield_hint">Message #composers</string> + <string name="swipe_to_cancel_recording">◀ Swipe to cancel</string> <string name="emojis_label">Emojis</string> <string name="stickers_label">Stickers</string> @@ -60,5 +61,13 @@ <string name="textfield_desc">Text input</string> <string name="not_available">Functionality currently not available</string> <string name="not_available_subtitle">Grab a beverage and check back later!</string> + <string name="attached_image">Attached image</string> + <string name="search">Search</string> + <string name="info">Information</string> + <string name="more_options">More options</string> + <string name="touch_and_hold_to_record">Touch and hold to record</string> + <string name="record_message">Record voice message</string> + <string name="messages_widget_title">JetChat unread messages</string> + <string name="add_widget_to_home_page">Add Widget to Home Page</string> </resources> diff --git a/Jetchat/app/src/main/res/values/themes.xml b/Jetchat/app/src/main/res/values/themes.xml index b429946d67..0e815ceaa4 100644 --- a/Jetchat/app/src/main/res/values/themes.xml +++ b/Jetchat/app/src/main/res/values/themes.xml @@ -18,7 +18,7 @@ <!-- Allows us to override platform level specific attributes in their respective values-vXX folder. --> - <style name="Platform.Theme.Jetchat" parent="Theme.MaterialComponents.DayNight.DarkActionBar"> + <style name="Platform.Theme.Jetchat" parent="android:Theme.Material.Light.NoActionBar"> <item name="android:statusBarColor">@color/black30</item> </style> diff --git a/Jetchat/app/src/main/res/xml/widget_unread_messages_info.xml b/Jetchat/app/src/main/res/xml/widget_unread_messages_info.xml new file mode 100644 index 0000000000..69ea543dc8 --- /dev/null +++ b/Jetchat/app/src/main/res/xml/widget_unread_messages_info.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" + android:initialLayout="@layout/glance_default_loading_layout" + android:minWidth="276dp" + android:minHeight="102dp" + android:previewImage="@drawable/widget_icon" + android:resizeMode="none" + android:targetCellWidth="4" + android:targetCellHeight="3" /> \ No newline at end of file diff --git a/Jetchat/build.gradle b/Jetchat/build.gradle deleted file mode 100644 index 4db919b9cd..0000000000 --- a/Jetchat/build.gradle +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -import com.example.compose.jetchat.buildsrc.Libs -import com.example.compose.jetchat.buildsrc.Urls -import com.example.compose.jetchat.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.7.0' -} - -subprojects { - repositories { - google() - mavenCentral() - jcenter() - - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { url Urls.composeSnapshotRepo } - maven { url Urls.accompanistSnapshotRepo } - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - ktlint(Versions.ktlint).userData([android: "true"]) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - - // Enable experimental coroutines APIs, including Flow - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' - freeCompilerArgs += '-Xopt-in=kotlin.Experimental' - freeCompilerArgs += '-Xallow-jvm-ir-dependencies' - - // Set JVM target to 1.8 - jvmTarget = "1.8" - } - } -} diff --git a/Jetchat/build.gradle.kts b/Jetchat/build.gradle.kts new file mode 100644 index 0000000000..30355ffe44 --- /dev/null +++ b/Jetchat/build.gradle.kts @@ -0,0 +1,73 @@ +/* + * 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 + alias(libs.plugins.spotless) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure<com.diffplug.gradle.spotless.SpotlessExtension> { + ratchetFrom = "origin/main" + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt") + ktlint().editorConfigOverride( + mapOf( + "ktlint_code_style" to "android_studio", + "ij_kotlin_allow_trailing_comma" to true, + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + // 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"), "(^(?![\\/ ]\\*).*$)") + } + kotlinGradle { + target("*.gradle.kts") + ktlint() + } + } +} diff --git a/Jetchat/buildSrc/build.gradle.kts b/Jetchat/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Jetchat/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt b/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt deleted file mode 100644 index a8913d14a3..0000000000 --- a/Jetchat/buildSrc/src/main/java/com/example/compose/jetchat/buildsrc/dependencies.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetchat.buildsrc - -object Versions { - const val ktlint = "0.39.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha04" - const val jdkDesugar = "com.android.tools:desugar_jdk_libs:1.0.9" - - const val junit = "junit:junit:4.13" - - const val material = "com.google.android.material:material:1.1.0" - - object Kotlin { - private const val version = "1.4.21" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.2" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object Accompanist { - private const val version = "0.4.2" - const val insets = "dev.chrisbanes.accompanist:accompanist-insets:$version" - } - - object AndroidX { - const val appcompat = "androidx.appcompat:appcompat:1.2.0-rc01" - const val coreKtx = "androidx.core:core-ktx:1.5.0-beta01" - - object Compose { - const val snapshot = "" - const val version = "1.0.0-alpha10" - - const val foundation = "androidx.compose.foundation:foundation:$version" - const val layout = "androidx.compose.foundation:foundation-layout:$version" - const val material = "androidx.compose.material:material:$version" - const val materialIconsExtended = "androidx.compose.material:material-icons-extended:$version" - const val runtime = "androidx.compose.runtime:runtime:$version" - const val runtimeLivedata = "androidx.compose.runtime:runtime-livedata:$version" - const val tooling = "androidx.compose.ui:ui-tooling:$version" - const val test = "androidx.compose.ui:ui-test:$version" - const val uiTest = "androidx.compose.ui:ui-test-junit4:$version" - const val uiUtil = "androidx.compose.ui:ui-util:${version}" - const val viewBinding = "androidx.compose.ui:ui-viewbinding:$version" - } - - object Navigation { - private const val version = "2.3.0" - const val fragment = "androidx.navigation:navigation-fragment-ktx:$version" - const val uiKtx = "androidx.navigation:navigation-ui-ktx:$version" - } - - object Test { - private const val version = "1.2.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - - object Lifecycle { - private const val version = "2.2.0" - const val extensions = "androidx.lifecycle:lifecycle-extensions:$version" - const val livedata = "androidx.lifecycle:lifecycle-livedata-ktx:$version" - const val viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - } - } -} - -object Urls { - const val composeSnapshotRepo = "https://androidx.dev/snapshots/builds/" + - "${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" - const val accompanistSnapshotRepo = "https://oss.sonatype.org/content/repositories/snapshots" -} diff --git a/Jetchat/buildscripts/toml-updater-config.gradle b/Jetchat/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/Jetchat/buildscripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/Jetchat/debug.keystore b/Jetchat/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Jetchat/debug.keystore and /dev/null differ diff --git a/Jetchat/debug_2.keystore b/Jetchat/debug_2.keystore new file mode 100644 index 0000000000..b42c971788 Binary files /dev/null and b/Jetchat/debug_2.keystore differ diff --git a/Jetchat/gradle.properties b/Jetchat/gradle.properties index b2d834ce9c..9299bc6d0f 100644 --- a/Jetchat/gradle.properties +++ b/Jetchat/gradle.properties @@ -37,6 +37,3 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Jetchat/gradle/libs.versions.toml b/Jetchat/gradle/libs.versions.toml new file mode 100644 index 0000000000..29943df2e6 --- /dev/null +++ b/Jetchat/gradle/libs.versions.toml @@ -0,0 +1,177 @@ +##### +# This file is duplicated to individual samples from the global scripts/libs.versions.toml +# Do not add a dependency to an individual sample, edit the global version instead. +##### +[versions] +accompanist = "0.37.2" +android-material3 = "1.13.0-alpha13" +androidGradlePlugin = "8.9.2" +androidx-activity-compose = "1.10.1" +androidx-appcompat = "1.7.0" +androidx-compose-bom = "2025.04.01" +androidx-constraintlayout = "1.1.0" +androidx-core-splashscreen = "1.0.1" +androidx-corektx = "1.16.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.9" +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.2" +google-maps = "18.2.0" +gradle-versions = "0.52.0" +hilt = "2.56.2" +hiltExt = "1.2.0" +horologist = "0.6.23" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.1.20" +kotlinx-serialization-json = "1.7.3" +kotlinx_immutable = "0.3.8" +ksp = "2.1.20-2.0.0" +maps-compose = "3.1.1" +# @keep +minSdk = "21" +okhttp = "4.12.0" +play-services-wearable = "18.1.0" +robolectric = "4.14.1" +roborazzi = "1.43.1" +rome = "2.1.0" +room = "2.7.1" +secrets = "2.0.1" +spotless = "7.0.3" +# @keep +targetSdk = "33" +version-catalog-update = "1.0.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = "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" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/Jetchat/gradle/wrapper/gradle-wrapper.jar b/Jetchat/gradle/wrapper/gradle-wrapper.jar index f6b961fd5a..7454180f2a 100644 Binary files a/Jetchat/gradle/wrapper/gradle-wrapper.jar and b/Jetchat/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Jetchat/gradle/wrapper/gradle-wrapper.properties b/Jetchat/gradle/wrapper/gradle-wrapper.properties index 2f4c55bdf8..d6c8bc7bf8 100644 --- a/Jetchat/gradle/wrapper/gradle-wrapper.properties +++ b/Jetchat/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,4 @@ -# -# Copyright 2020 The Android Open Source Project +# 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. @@ -12,11 +11,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -# -#Mon Apr 13 12:05:46 CEST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-rc-1-bin.zip diff --git a/Jetchat/gradlew b/Jetchat/gradlew index cccdd3d517..744e882ed5 100755 --- a/Jetchat/gradlew +++ b/Jetchat/gradlew @@ -1,5 +1,21 @@ #!/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 @@ -28,7 +44,7 @@ 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="" +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" @@ -56,7 +72,7 @@ case "`uname`" in Darwin* ) darwin=true ;; - MINGW* ) + MSYS* | MINGW* ) msys=true ;; NONSTOP* ) @@ -66,6 +82,7 @@ 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 @@ -109,10 +126,11 @@ if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then +# 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 @@ -138,19 +156,19 @@ if $cygwin ; then else eval `echo args$i`="\"$arg\"" fi - i=$((i+1)) + 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" ;; + 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 @@ -159,14 +177,9 @@ save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } -APP_ARGS=$(save "$@") +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" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - exec "$JAVACMD" "$@" diff --git a/Jetchat/gradlew.bat b/Jetchat/gradlew.bat index e95643d6a2..ac1b06f938 100644 --- a/Jetchat/gradlew.bat +++ b/Jetchat/gradlew.bat @@ -1,3 +1,19 @@ +@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 @@ -13,15 +29,18 @@ 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= +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 init +if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,28 +64,14 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :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 %CMD_LINE_ARGS% +"%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 diff --git a/Jetchat/screenshots/ime-transition.gif b/Jetchat/screenshots/ime-transition.gif deleted file mode 100644 index 7532d97bda..0000000000 Binary files a/Jetchat/screenshots/ime-transition.gif and /dev/null differ diff --git a/Jetchat/screenshots/jetchat.gif b/Jetchat/screenshots/jetchat.gif index d11a349f88..01217ebb57 100644 Binary files a/Jetchat/screenshots/jetchat.gif and b/Jetchat/screenshots/jetchat.gif differ diff --git a/Jetchat/screenshots/screenshots.png b/Jetchat/screenshots/screenshots.png new file mode 100644 index 0000000000..f03282664f Binary files /dev/null and b/Jetchat/screenshots/screenshots.png differ diff --git a/Jetchat/screenshots/widget.png b/Jetchat/screenshots/widget.png new file mode 100644 index 0000000000..7a3cf48e39 Binary files /dev/null and b/Jetchat/screenshots/widget.png differ diff --git a/Jetchat/screenshots/widget_discoverability.png b/Jetchat/screenshots/widget_discoverability.png new file mode 100644 index 0000000000..a59e4d5b09 Binary files /dev/null and b/Jetchat/screenshots/widget_discoverability.png differ diff --git a/Jetchat/settings.gradle b/Jetchat/settings.gradle deleted file mode 100644 index 4c2a42f96d..0000000000 --- a/Jetchat/settings.gradle +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -include ':app' -rootProject.name = "Jetchat" \ No newline at end of file diff --git a/Jetchat/settings.gradle.kts b/Jetchat/settings.gradle.kts new file mode 100644 index 0000000000..7ce9640e5e --- /dev/null +++ b/Jetchat/settings.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +val snapshotVersion : String? = System.getenv("COMPOSE_SNAPSHOT_ID") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + snapshotVersion?.let { + println("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") + maven { url = uri("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") } + } + + google() + mavenCentral() + } +} +rootProject.name = "Jetchat" +include(":app") + diff --git a/Jetsnack/.gitignore b/Jetsnack/.gitignore index aa724b7707..834ecd9dff 100644 --- a/Jetsnack/.gitignore +++ b/Jetsnack/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +.kotlin/ diff --git a/Jetsnack/.google/packaging.yaml b/Jetsnack/.google/packaging.yaml index 1d49c703c6..92e33aec34 100644 --- a/Jetsnack/.google/packaging.yaml +++ b/Jetsnack/.google/packaging.yaml @@ -18,10 +18,16 @@ # End users may safely ignore this file. It has no relevance to other systems. --- status: PUBLISHED -technologies: [Android] -categories: [Compose] +technologies: [Android, JetpackCompose] +categories: + - JetpackComposeDesignSystems + - JetpackComposeAnimation + - JetpackComposeLayouts languages: [Kotlin] -solutions: [Mobile] +solutions: + - Mobile + - JetpackLifecycle + - JetpackNavigation github: android/compose-samples level: ADVANCED apiRefs: diff --git a/Jetsnack/README.md b/Jetsnack/README.md index e866c3a901..d738b3552d 100644 --- a/Jetsnack/README.md +++ b/Jetsnack/README.md @@ -2,7 +2,8 @@ Jetsnack is a sample snack ordering app built with [Jetpack Compose][compose]. -To try out these sample apps, you need to use the latest Canary version of Android Studio 4.2. +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). You can clone this repository or import the project from Android Studio following the steps [here](https://developer.android.com/jetpack/compose/setup#sample). @@ -13,7 +14,9 @@ This sample showcases: * Custom layout * Animation -<img src="screenshots/jetsnack.gif"/> +## Screenshots + +<img src="screenshots/screenshots.png"/> ### Status: 🚧 In progress 🚧 @@ -50,8 +53,10 @@ Jetsnack utilizes custom [`Layout`](https://developer.android.com/reference/kotl ## Data Domain types are modelled in the [model package](app/src/main/java/com/example/jetsnack/model), each containing static sample data exposed using fake `Repo`s objects. -Imagery is sourced from [Unsplash](https://unsplash.com/) and loaded using [coil-accompanist][coil-accompanist]. +Imagery is sourced from [Unsplash](https://unsplash.com/) and loaded using the [Coil][coil] library. +## Baseline Profiles +For [Baseline profiles](https://developer.android.com/topic/performance/baselineprofiles), see the [compose-latest](https://github.com/android/compose-samples/tree/compose-latest/Jetsnack) branch. ## License @@ -72,4 +77,4 @@ limitations under the License. ``` [compose]: https://developer.android.com/jetpack/compose -[coil-accompanist]: https://github.com/chrisbanes/accompanist +[coil]: https://coil-kt.github.io/coil/ diff --git a/Jetsnack/app/build.gradle b/Jetsnack/app/build.gradle deleted file mode 100644 index 496eafbd84..0000000000 --- a/Jetsnack/app/build.gradle +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.jetsnack.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' - id 'kotlin-parcelize' -} - -android { - compileSdkVersion 30 - - defaultConfig { - applicationId 'com.example.jetsnack' - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName '1.0' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - buildFeatures { - compose true - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerVersion Libs.Kotlin.version - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - implementation Libs.Coroutines.core - - implementation Libs.AndroidX.coreKtx - - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.foundation - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.ui - implementation Libs.AndroidX.Compose.uiUtil - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.animation - implementation Libs.AndroidX.Compose.iconsExtended - implementation Libs.AndroidX.Compose.tooling - - implementation Libs.Accompanist.coil - implementation Libs.Accompanist.insets -} diff --git a/Jetsnack/app/build.gradle.kts b/Jetsnack/app/build.gradle.kts new file mode 100644 index 0000000000..6bf85b3809 --- /dev/null +++ b/Jetsnack/app/build.gradle.kts @@ -0,0 +1,134 @@ +/* + * 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.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.jetsnack" + + defaultConfig { + applicationId = "com.example.jetsnack" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + // Important: change the keystore for a production deployment + val userKeystore = File(System.getProperty("user.home"), ".android/debug.keystore") + val localKeystore = rootProject.file("debug_2.keystore") + val hasKeyInfo = userKeystore.exists() + create("release") { + storeFile = if (hasKeyInfo) userKeystore else localKeystore + storePassword = if (hasKeyInfo) "android" else System.getenv("compose_store_password") + keyAlias = if (hasKeyInfo) "androiddebugkey" else System.getenv("compose_key_alias") + keyPassword = if (hasKeyInfo) "android" else System.getenv("compose_key_password") + } + } + + buildTypes { + getByName("debug") { + + } + + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("release") + matchingFallbacks.add("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-benchmark-rules.pro" + ) + isDebuggable = false + } + } + + kotlinOptions { + jvmTarget = "17" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging.resources { + // Multiple dependency bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.constraintlayout.compose) + + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.util) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.material.iconsExtended) + 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) +} diff --git a/Jetsnack/app/proguard-benchmark-rules.pro b/Jetsnack/app/proguard-benchmark-rules.pro new file mode 100644 index 0000000000..5849b43aae --- /dev/null +++ b/Jetsnack/app/proguard-benchmark-rules.pro @@ -0,0 +1,28 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses + +# When generating the baseline profile we want the proper names of +# the methods and classes +-dontobfuscate \ No newline at end of file diff --git a/Jetsnack/app/proguard-rules.pro b/Jetsnack/app/proguard-rules.pro index 4cb94585a0..f8f76182de 100644 --- a/Jetsnack/app/proguard-rules.pro +++ b/Jetsnack/app/proguard-rules.pro @@ -22,3 +22,16 @@ # Repackage classes into the top-level. -repackageclasses + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE + +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } \ No newline at end of file diff --git a/Jetsnack/app/src/androidTest/java/com/example/jetsnack/AppTest.kt b/Jetsnack/app/src/androidTest/java/com/example/jetsnack/AppTest.kt new file mode 100644 index 0000000000..646a3d6a4f --- /dev/null +++ b/Jetsnack/app/src/androidTest/java/com/example/jetsnack/AppTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.example.jetsnack.ui.MainActivity +import org.junit.Rule +import org.junit.Test + +class AppTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule<MainActivity>() + + @Test + fun app_launches() { + // Check app launches at the correct destination + composeTestRule.onNodeWithText("HOME").assertIsDisplayed() + composeTestRule.onNodeWithText("Android's picks").assertIsDisplayed() + } + + @Test + fun app_canNavigateToAllScreens() { + // Check app launches at HOME + composeTestRule.onNodeWithText("HOME").assertIsDisplayed() + composeTestRule.onNodeWithText("Android's picks").assertIsDisplayed() + + // Navigate to Search + composeTestRule.onNodeWithText("SEARCH").performClick().assertIsDisplayed() + composeTestRule.onNodeWithText("Categories").assertIsDisplayed() + + // Navigate to Cart + composeTestRule.onNodeWithText("MY CART").performClick().assertIsDisplayed() + composeTestRule.onNodeWithText("Order (3 items)").assertIsDisplayed() + + // Navigate to Profile + composeTestRule.onNodeWithText("PROFILE").performClick().assertIsDisplayed() + composeTestRule.onNodeWithText("This is currently work in progress").assertIsDisplayed() + } + + @Test + fun app_canNavigateToDetailPage() { + composeTestRule.onNodeWithText("Chips").performClick() + composeTestRule.onNodeWithText("Lorem ipsum", substring = true).assertIsDisplayed() + } +} diff --git a/Jetsnack/app/src/main/AndroidManifest.xml b/Jetsnack/app/src/main/AndroidManifest.xml index c98807608e..a494a7af72 100644 --- a/Jetsnack/app/src/main/AndroidManifest.xml +++ b/Jetsnack/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- +<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except @@ -13,7 +12,7 @@ the License. --> <manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.example.jetsnack"> + xmlns:tools="http://schemas.android.com/tools"> <!--Load images from Unsplash--> <uses-permission android:name="android.permission.INTERNET" /> @@ -23,16 +22,25 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" - android:theme="@style/Theme.Jetsnack"> + android:theme="@style/Theme.Jetsnack" + + > + + <profileable + android:shell="true" + tools:targetApi="q" /> + <activity android:name=".ui.MainActivity" - android:label="@string/app_name" - android:theme="@style/Theme.Jetsnack"> + android:exported="true" + android:theme="@style/Theme.Jetsnack" + android:windowSoftInputMode="adjustResize"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> + </application> </manifest> diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Filter.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Filter.kt index 52917f2ef4..8b0e8ddf9c 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Filter.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Filter.kt @@ -16,13 +16,19 @@ package com.example.jetsnack.model +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.SortByAlpha +import androidx.compose.material.icons.filled.Star import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.vector.ImageVector @Stable class Filter( val name: String, - enabled: Boolean = false + enabled: Boolean = false, + val icon: ImageVector? = null ) { val enabled = mutableStateOf(enabled) } @@ -34,3 +40,30 @@ val filters = listOf( Filter(name = "Sweet"), Filter(name = "Savory") ) +val priceFilters = listOf( + Filter(name = "$"), + Filter(name = "$$"), + Filter(name = "$$$"), + Filter(name = "$$$$") +) +val sortFilters = listOf( + Filter(name = "Android's favorite (default)", icon = Icons.Filled.Android), + Filter(name = "Rating", icon = Icons.Filled.Star), + Filter(name = "Alphabetical", icon = Icons.Filled.SortByAlpha) +) + +val categoryFilters = listOf( + Filter(name = "Chips & crackers"), + Filter(name = "Fruit snacks"), + Filter(name = "Desserts"), + Filter(name = "Nuts") +) +val lifeStyleFilters = listOf( + Filter(name = "Organic"), + Filter(name = "Gluten-free"), + Filter(name = "Dairy-free"), + Filter(name = "Sweet"), + Filter(name = "Savory") +) + +var sortDefault = sortFilters.get(0).name diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Search.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Search.kt index d22a41edb4..28eda384f0 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Search.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Search.kt @@ -17,6 +17,7 @@ package com.example.jetsnack.model import androidx.compose.runtime.Immutable +import com.example.jetsnack.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -44,7 +45,7 @@ data class SearchCategoryCollection( @Immutable data class SearchCategory( val name: String, - val imageUrl: String + val imageRes: Int ) @Immutable @@ -65,19 +66,19 @@ private val searchCategoryCollections = listOf( categories = listOf( SearchCategory( name = "Chips & crackers", - imageUrl = "https://source.unsplash.com/UsSdMZ78Q3E" + imageRes = R.drawable.chips ), SearchCategory( name = "Fruit snacks", - imageUrl = "https://source.unsplash.com/SfP1PtM9Qa8" + imageRes = R.drawable.fruit, ), SearchCategory( name = "Desserts", - imageUrl = "https://source.unsplash.com/_jk8KIyN_uA" + imageRes = R.drawable.desserts ), SearchCategory( - name = "Nuts ", - imageUrl = "https://source.unsplash.com/UsSdMZ78Q3E" + name = "Nuts", + imageRes = R.drawable.nuts, ) ) ), @@ -87,27 +88,27 @@ private val searchCategoryCollections = listOf( categories = listOf( SearchCategory( name = "Organic", - imageUrl = "https://source.unsplash.com/7meCnGCJ5Ms" + imageRes = R.drawable.organic ), SearchCategory( name = "Gluten Free", - imageUrl = "https://source.unsplash.com/m741tj4Cz7M" + imageRes = R.drawable.gluten_free ), SearchCategory( name = "Paleo", - imageUrl = "https://source.unsplash.com/dt5-8tThZKg" + imageRes = R.drawable.paleo, ), SearchCategory( name = "Vegan", - imageUrl = "https://source.unsplash.com/ReXxkS1m1H0" + imageRes = R.drawable.vegan, ), SearchCategory( - name = "Vegitarian", - imageUrl = "https://source.unsplash.com/IGfIGP5ONV0" + name = "Vegetarian", + imageRes = R.drawable.organic, ), SearchCategory( name = "Whole30", - imageUrl = "https://source.unsplash.com/9MzCd76xLGk" + imageRes = R.drawable.paleo ) ) ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Snack.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Snack.kt index 4fc8a6d9e6..48fea46f1f 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/model/Snack.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/Snack.kt @@ -16,13 +16,17 @@ package com.example.jetsnack.model +import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable +import com.example.jetsnack.R +import kotlin.random.Random @Immutable data class Snack( val id: Long, val name: String, - val imageUrl: String, + @DrawableRes + val imageRes: Int, val price: Long, val tagline: String = "", val tags: Set<String> = emptySet() @@ -37,190 +41,190 @@ val snacks = listOf( id = 1L, name = "Cupcake", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/pGM4sjt_BdQ", + imageRes = R.drawable.cupcake, price = 299 ), Snack( - id = 2L, + id = Random.nextLong(), name = "Donut", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/Yc5sL-ejk6U", + imageRes = R.drawable.donut, price = 299 ), Snack( - id = 3L, + id = Random.nextLong(), name = "Eclair", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/-LojFX9NfPY", + imageRes = R.drawable.eclair, price = 299 ), Snack( - id = 4L, + id = Random.nextLong(), name = "Froyo", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/3U2V5WqK1PQ", + imageRes = R.drawable.froyo, price = 299 ), Snack( - id = 5L, + id = Random.nextLong(), name = "Gingerbread", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/Y4YR9OjdIMk", + imageRes = R.drawable.gingerbread, price = 499 ), Snack( - id = 6L, + id = Random.nextLong(), name = "Honeycomb", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/bELvIg_KZGU", + imageRes = R.drawable.honeycomb, price = 299 ), Snack( - id = 7L, + id = Random.nextLong(), name = "Ice Cream Sandwich", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/AqorcpZIKnU", + imageRes = R.drawable.ice_cream_sandwich, price = 1299 ), Snack( - id = 8L, + id = Random.nextLong(), name = "Jellybean", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/0u_vbeOkMpk", + imageRes = R.drawable.jelly_bean, price = 299 ), Snack( - id = 9L, + id = Random.nextLong(), name = "KitKat", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/yb16pT5F_jE", + imageRes = R.drawable.kitkat, price = 549 ), Snack( - id = 10L, + id = Random.nextLong(), name = "Lollipop", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/AHF_ZktTL6Q", + imageRes = R.drawable.lollipop, price = 299 ), Snack( - id = 11L, + id = Random.nextLong(), name = "Marshmallow", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/rqFm0IgMVYY", + imageRes = R.drawable.marshmallow, price = 299 ), Snack( - id = 12L, + id = Random.nextLong(), name = "Nougat", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/qRE_OpbVPR8", + imageRes = R.drawable.nougat, price = 299 ), Snack( - id = 13L, + id = Random.nextLong(), name = "Oreo", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/33fWPnyN6tU", + imageRes = R.drawable.oreo, price = 299 ), Snack( - id = 14L, + id = Random.nextLong(), name = "Pie", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/aX_ljOOyWJY", + imageRes = R.drawable.pie, price = 299 ), Snack( - id = 15L, + id = Random.nextLong(), name = "Chips", - imageUrl = "https://source.unsplash.com/UsSdMZ78Q3E", + imageRes = R.drawable.chips, price = 299 ), Snack( - id = 16L, + id = Random.nextLong(), name = "Pretzels", - imageUrl = "https://source.unsplash.com/7meCnGCJ5Ms", + imageRes = R.drawable.pretzels, price = 299 ), Snack( - id = 17L, + id = Random.nextLong(), name = "Smoothies", - imageUrl = "https://source.unsplash.com/m741tj4Cz7M", + imageRes = R.drawable.smoothies, price = 299 ), Snack( - id = 18L, + id = Random.nextLong(), name = "Popcorn", - imageUrl = "https://source.unsplash.com/iuwMdNq0-s4", + imageRes = R.drawable.popcorn, price = 299 ), Snack( - id = 19L, + id = Random.nextLong(), name = "Almonds", - imageUrl = "https://source.unsplash.com/qgWWQU1SzqM", + imageRes = R.drawable.almonds, price = 299 ), Snack( - id = 20L, + id = Random.nextLong(), name = "Cheese", - imageUrl = "https://source.unsplash.com/9MzCd76xLGk", + imageRes = R.drawable.cheese, price = 299 ), Snack( - id = 21L, + id = Random.nextLong(), name = "Apples", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/1d9xXWMtQzQ", + imageRes = R.drawable.apples, price = 299 ), Snack( - id = 22L, + id = Random.nextLong(), name = "Apple sauce", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/wZxpOw84QTU", + imageRes = R.drawable.apple_sauce, price = 299 ), Snack( - id = 23L, + id = Random.nextLong(), name = "Apple chips", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/okzeRxm_GPo", + imageRes = R.drawable.apple_chips, price = 299 ), Snack( - id = 24L, + id = Random.nextLong(), name = "Apple juice", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/l7imGdupuhU", + imageRes = R.drawable.apple_juice, price = 299 ), Snack( - id = 25L, + id = Random.nextLong(), name = "Apple pie", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/bkXzABDt08Q", + imageRes = R.drawable.apple_pie, price = 299 ), Snack( - id = 26L, + id = Random.nextLong(), name = "Grapes", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/y2MeW00BdBo", + imageRes = R.drawable.grapes, price = 299 ), Snack( - id = 27L, + id = Random.nextLong(), name = "Kiwi", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/1oMGgHn-M8k", + imageRes = R.drawable.kiwi, price = 299 ), Snack( - id = 28L, + id = Random.nextLong(), name = "Mango", tagline = "A tag line", - imageUrl = "https://source.unsplash.com/TIGDsyy0TK4", + imageRes = R.drawable.mango, price = 299 ) ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackCollection.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackCollection.kt index 77da39594b..ad12b40284 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackCollection.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackCollection.kt @@ -17,6 +17,7 @@ package com.example.jetsnack.model import androidx.compose.runtime.Immutable +import kotlin.random.Random @Immutable data class SnackCollection( @@ -37,7 +38,12 @@ object SnackRepo { fun getRelated(@Suppress("UNUSED_PARAMETER") snackId: Long) = related fun getInspiredByCart() = inspiredByCart fun getFilters() = filters + fun getPriceFilters() = priceFilters fun getCart() = cart + fun getSortFilters() = sortFilters + fun getCategoryFilters() = categoryFilters + fun getSortDefault() = sortDefault + fun getLifeStyleFilters() = lifeStyleFilters } /** @@ -52,33 +58,33 @@ private val tastyTreats = SnackCollection( ) private val popular = SnackCollection( - id = 2L, + id = Random.nextLong(), name = "Popular on Jetsnack", snacks = snacks.subList(14, 19) ) private val wfhFavs = tastyTreats.copy( - id = 3L, + id = Random.nextLong(), name = "WFH favourites" ) private val newlyAdded = popular.copy( - id = 4L, + id = Random.nextLong(), name = "Newly Added" ) private val exclusive = tastyTreats.copy( - id = 5L, + id = Random.nextLong(), name = "Only on Jetsnack" ) private val also = tastyTreats.copy( - id = 6L, + id = Random.nextLong(), name = "Customers also bought" ) private val inspiredByCart = tastyTreats.copy( - id = 7L, + id = Random.nextLong(), name = "Inspired by your cart" ) @@ -91,8 +97,8 @@ private val snackCollections = listOf( ) private val related = listOf( - also, - popular + also.copy(id = Random.nextLong()), + popular.copy(id = Random.nextLong()) ) private val cart = listOf( diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackbarManager.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackbarManager.kt new file mode 100644 index 0000000000..4098d4057c --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/model/SnackbarManager.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.model + +import androidx.annotation.StringRes +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class Message(val id: Long, @StringRes val messageId: Int) + +/** + * Class responsible for managing Snackbar messages to show on the screen + */ +object SnackbarManager { + + private val _messages: MutableStateFlow<List<Message>> = MutableStateFlow(emptyList()) + val messages: StateFlow<List<Message>> get() = _messages.asStateFlow() + + fun showMessage(@StringRes messageTextId: Int) { + _messages.update { currentMessages -> + currentMessages + Message( + id = UUID.randomUUID().mostSignificantBits, + messageId = messageTextId + ) + } + } + + fun setMessageShown(messageId: Long) { + _messages.update { currentMessages -> + currentMessages.filterNot { it.id == messageId } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt index 405e81537a..04991fa1cf 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/JetsnackApp.kt @@ -14,38 +14,160 @@ * limitations under the License. */ +@file:OptIn( + ExperimentalSharedTransitionApi::class +) + package com.example.jetsnack.ui -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.animation.Crossfade +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.savedinstancestate.rememberSavedInstanceState -import com.example.jetsnack.ui.home.Home +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.navArgument +import com.example.jetsnack.ui.components.JetsnackScaffold +import com.example.jetsnack.ui.components.JetsnackSnackbar +import com.example.jetsnack.ui.components.rememberJetsnackScaffoldState +import com.example.jetsnack.ui.home.HomeSections +import com.example.jetsnack.ui.home.JetsnackBottomBar +import com.example.jetsnack.ui.home.addHomeGraph +import com.example.jetsnack.ui.home.composableWithCompositionLocal +import com.example.jetsnack.ui.navigation.MainDestinations +import com.example.jetsnack.ui.navigation.rememberJetsnackNavController import com.example.jetsnack.ui.snackdetail.SnackDetail +import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring +import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring import com.example.jetsnack.ui.theme.JetsnackTheme -import com.example.jetsnack.ui.utils.Navigator -import dev.chrisbanes.accompanist.insets.ProvideWindowInsets +@Preview @Composable -fun JetsnackApp(backDispatcher: OnBackPressedDispatcher) { - val navigator: Navigator<Destination> = rememberSavedInstanceState( - saver = Navigator.saver<Destination>(backDispatcher) - ) { - Navigator(Destination.Home, backDispatcher) +fun JetsnackApp() { + JetsnackTheme { + val jetsnackNavController = rememberJetsnackNavController() + SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this + ) { + NavHost( + navController = jetsnackNavController.navController, + startDestination = MainDestinations.HOME_ROUTE + ) { + composableWithCompositionLocal( + route = MainDestinations.HOME_ROUTE + ) { backStackEntry -> + MainContainer( + onSnackSelected = jetsnackNavController::navigateToSnackDetail + ) + } + + composableWithCompositionLocal( + "${MainDestinations.SNACK_DETAIL_ROUTE}/" + + "{${MainDestinations.SNACK_ID_KEY}}" + + "?origin={${MainDestinations.ORIGIN}}", + arguments = listOf( + navArgument(MainDestinations.SNACK_ID_KEY) { + type = NavType.LongType + } + ), + + ) { backStackEntry -> + val arguments = requireNotNull(backStackEntry.arguments) + val snackId = arguments.getLong(MainDestinations.SNACK_ID_KEY) + val origin = arguments.getString(MainDestinations.ORIGIN) + SnackDetail( + snackId, + origin = origin ?: "", + upPress = jetsnackNavController::upPress + ) + } + } + } + } } - val actions = remember(navigator) { Actions(navigator) } - ProvideWindowInsets { - JetsnackTheme { - Crossfade(navigator.current) { destination -> - when (destination) { - Destination.Home -> Home(actions.selectSnack) - is Destination.SnackDetail -> SnackDetail( - snackId = destination.snackId, - upPress = actions.upPress +} + +@Composable +fun MainContainer( + modifier: Modifier = Modifier, + onSnackSelected: (Long, String, NavBackStackEntry) -> Unit +) { + val jetsnackScaffoldState = rememberJetsnackScaffoldState() + val nestedNavController = rememberJetsnackNavController() + val navBackStackEntry by nestedNavController.navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No SharedElementScope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No SharedElementScope found") + JetsnackScaffold( + bottomBar = { + with(animatedVisibilityScope) { + with(sharedTransitionScope) { + JetsnackBottomBar( + tabs = HomeSections.entries.toTypedArray(), + currentRoute = currentRoute ?: HomeSections.FEED.route, + navigateToRoute = nestedNavController::navigateToBottomBarRoute, + modifier = Modifier + .renderInSharedTransitionScopeOverlay( + zIndexInOverlay = 1f, + ) + .animateEnterExit( + enter = fadeIn(nonSpatialExpressiveSpring()) + slideInVertically( + spatialExpressiveSpring() + ) { + it + }, + exit = fadeOut(nonSpatialExpressiveSpring()) + slideOutVertically( + spatialExpressiveSpring() + ) { + it + } + ) ) } } + }, + modifier = modifier, + snackbarHost = { + SnackbarHost( + hostState = it, + modifier = Modifier.systemBarsPadding(), + snackbar = { snackbarData -> JetsnackSnackbar(snackbarData) } + ) + }, + snackBarHostState = jetsnackScaffoldState.snackBarHostState, + ) { padding -> + NavHost( + navController = nestedNavController.navController, + startDestination = HomeSections.FEED.route + ) { + addHomeGraph( + onSnackSelected = onSnackSelected, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) } } } + +val LocalNavAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null } +val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/MainActivity.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/MainActivity.kt index a9e22022ad..4c1ee27c01 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/MainActivity.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/MainActivity.kt @@ -18,25 +18,13 @@ package com.example.jetsnack.ui import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.compose.runtime.Providers -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.setContent -import androidx.core.view.WindowCompat -import com.example.jetsnack.ui.utils.SysUiController -import com.example.jetsnack.ui.utils.SystemUiController +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) - - // This app draws behind the system bars, so we want to handle fitting system windows - WindowCompat.setDecorFitsSystemWindows(window, false) - - setContent { - val systemUiController = remember { SystemUiController(window) } - Providers(SysUiController provides systemUiController) { - JetsnackApp(onBackPressedDispatcher) - } - } + setContent { JetsnackApp() } } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/NavGraph.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/NavGraph.kt deleted file mode 100644 index 19b8cc7425..0000000000 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/NavGraph.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetsnack.ui - -import android.os.Parcelable -import androidx.compose.runtime.Immutable -import com.example.jetsnack.ui.utils.Navigator -import kotlinx.parcelize.Parcelize - -/** - * Models the screens in the app and any arguments they require. - */ -sealed class Destination : Parcelable { - @Parcelize - object Home : Destination() - - @Immutable - @Parcelize - data class SnackDetail(val snackId: Long) : Destination() -} - -/** - * Models the navigation actions in the app. - */ -class Actions(navigator: Navigator<Destination>) { - val selectSnack: (Long) -> Unit = { snackId: Long -> - navigator.navigate(Destination.SnackDetail(snackId)) - } - val upPress: () -> Unit = { - navigator.back() - } -} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/SnackSharedElementKey.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/SnackSharedElementKey.kt new file mode 100644 index 0000000000..a40b6091d0 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/SnackSharedElementKey.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.ui + +data class SnackSharedElementKey( + val snackId: Long, + val origin: String, + val type: SnackSharedElementType +) + +enum class SnackSharedElementType { + Bounds, + Image, + Title, + Tagline, + Background +} + +object FilterSharedElementKey diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Button.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Button.kt index 789fecb572..9b345314ab 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Button.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Button.kt @@ -16,22 +16,24 @@ package com.example.jetsnack.ui.components -import androidx.compose.foundation.AmbientIndication +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.InteractionState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.defaultMinSizeConstraints +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ProvideTextStyle +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -39,16 +41,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview import com.example.jetsnack.ui.theme.JetsnackTheme @Composable + fun JetsnackButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - interactionState: InteractionState = remember { InteractionState() }, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = ButtonShape, border: BorderStroke? = null, backgroundGradient: List<Color> = JetsnackTheme.colors.interactivePrimary, @@ -74,20 +79,20 @@ fun JetsnackButton( onClick = onClick, enabled = enabled, role = Role.Button, - interactionState = interactionState, + interactionSource = interactionSource, indication = null ) ) { ProvideTextStyle( - value = MaterialTheme.typography.button + value = MaterialTheme.typography.labelLarge ) { Row( Modifier - .defaultMinSizeConstraints( + .defaultMinSize( minWidth = ButtonDefaults.MinWidth, minHeight = ButtonDefaults.MinHeight ) - .indication(interactionState, AmbientIndication.current()) + .indication(interactionSource, ripple()) .padding(contentPadding), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, @@ -98,3 +103,29 @@ fun JetsnackButton( } private val ButtonShape = RoundedCornerShape(percent = 50) + +@Preview("default", "round") +@Preview("dark theme", "round", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", "round", fontScale = 2f) +@Composable +private fun ButtonPreview() { + JetsnackTheme { + JetsnackButton(onClick = {}) { + Text(text = "Demo") + } + } +} + +@Preview("default", "rectangle") +@Preview("dark theme", "rectangle", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", "rectangle", fontScale = 2f) +@Composable +private fun RectangleButtonPreview() { + JetsnackTheme { + JetsnackButton( + onClick = {}, shape = RectangleShape + ) { + Text(text = "Demo") + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Card.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Card.kt index 27bc0aec40..f89478e979 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Card.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Card.kt @@ -16,12 +16,16 @@ package com.example.jetsnack.ui.components +import android.content.res.Configuration import androidx.compose.foundation.BorderStroke -import androidx.compose.material.MaterialTheme +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.jetsnack.ui.theme.JetsnackTheme @@ -33,7 +37,7 @@ fun JetsnackCard( color: Color = JetsnackTheme.colors.uiBackground, contentColor: Color = JetsnackTheme.colors.textPrimary, border: BorderStroke? = null, - elevation: Dp = 1.dp, + elevation: Dp = 4.dp, content: @Composable () -> Unit ) { JetsnackSurface( @@ -46,3 +50,15 @@ fun JetsnackCard( content = content ) } + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +private fun CardPreview() { + JetsnackTheme { + JetsnackCard { + Text(text = "Demo", modifier = Modifier.padding(16.dp)) + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Divider.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Divider.kt index ba5169a50c..49a397bb7d 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Divider.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Divider.kt @@ -16,10 +16,15 @@ package com.example.jetsnack.ui.components -import androidx.compose.material.Divider +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.jetsnack.ui.theme.JetsnackTheme @@ -28,15 +33,24 @@ import com.example.jetsnack.ui.theme.JetsnackTheme fun JetsnackDivider( modifier: Modifier = Modifier, color: Color = JetsnackTheme.colors.uiBorder.copy(alpha = DividerAlpha), - thickness: Dp = 1.dp, - startIndent: Dp = 0.dp + thickness: Dp = 1.dp ) { - Divider( + HorizontalDivider( modifier = modifier, color = color, - thickness = thickness, - startIndent = startIndent + thickness = thickness ) } private const val DividerAlpha = 0.12f + +@Preview("default", showBackground = true) +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun DividerPreview() { + JetsnackTheme { + Box(Modifier.size(height = 10.dp, width = 100.dp)) { + JetsnackDivider(Modifier.align(Alignment.Center)) + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Filters.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Filters.kt index 6cf71bbc3a..5cc0fb263e 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Filters.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Filters.kt @@ -14,57 +14,88 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.example.jetsnack.ui.components -import androidx.compose.animation.animateAsState -import androidx.compose.foundation.ScrollableRow +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredHeightIn -import androidx.compose.foundation.layout.preferredWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.example.jetsnack.R import com.example.jetsnack.model.Filter +import com.example.jetsnack.ui.FilterSharedElementKey import com.example.jetsnack.ui.theme.JetsnackTheme @Composable -fun FilterBar(filters: List<Filter>) { - ScrollableRow(modifier = Modifier.preferredHeightIn(min = 56.dp)) { - Spacer(Modifier.preferredWidth(8.dp)) - IconButton( - onClick = { /* todo */ }, - modifier = Modifier.align(Alignment.CenterVertically) +fun FilterBar( + filters: List<Filter>, + onShowFilters: () -> Unit, + filterScreenVisible: Boolean, + sharedTransitionScope: SharedTransitionScope +) { + with(sharedTransitionScope) { + LazyRow( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 12.dp, end = 8.dp), + modifier = Modifier.heightIn(min = 56.dp) ) { - Icon( - imageVector = Icons.Rounded.FilterList, - tint = JetsnackTheme.colors.brand, - modifier = Modifier.diagonalGradientBorder( - colors = JetsnackTheme.colors.interactiveSecondary, - shape = CircleShape - ) - ) - } - - filters.forEach { filter -> - FilterChip( - filter = filter, - modifier = Modifier.align(Alignment.CenterVertically) - ) - Spacer(Modifier.preferredWidth(8.dp)) + item { + AnimatedVisibility(visible = !filterScreenVisible) { + IconButton( + onClick = onShowFilters, + modifier = Modifier + .sharedBounds( + rememberSharedContentState(FilterSharedElementKey), + animatedVisibilityScope = this@AnimatedVisibility, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds + ) + ) { + Icon( + imageVector = Icons.Rounded.FilterList, + tint = JetsnackTheme.colors.brand, + contentDescription = stringResource(R.string.label_filters), + modifier = Modifier.diagonalGradientBorder( + colors = JetsnackTheme.colors.interactiveSecondary, + shape = CircleShape + ) + ) + } + } + } + items(filters) { filter -> + FilterChip(filter = filter, shape = MaterialTheme.shapes.small) + } } } } @@ -76,35 +107,54 @@ fun FilterChip( shape: Shape = MaterialTheme.shapes.small ) { val (selected, setSelected) = filter.enabled - val backgroundColor by animateAsState( - if (selected) JetsnackTheme.colors.brand else JetsnackTheme.colors.uiBackground + val backgroundColor by animateColorAsState( + if (selected) JetsnackTheme.colors.brandSecondary else JetsnackTheme.colors.uiBackground, + label = "background color" ) val border = Modifier.fadeInDiagonalGradientBorder( showBorder = !selected, colors = JetsnackTheme.colors.interactiveSecondary, shape = shape ) - val textColor by animateAsState( - if (selected) JetsnackTheme.colors.textInteractive else JetsnackTheme.colors.textSecondary + val textColor by animateColorAsState( + if (selected) Color.Black else JetsnackTheme.colors.textSecondary, + label = "text color" ) + JetsnackSurface( - modifier = modifier - .preferredHeight(28.dp) - .then(border), + modifier = modifier, color = backgroundColor, contentColor = textColor, shape = shape, elevation = 2.dp ) { + val interactionSource = remember { MutableInteractionSource() } + + val pressed by interactionSource.collectIsPressedAsState() + val backgroundPressed = + if (pressed) { + Modifier.offsetGradientBackground( + JetsnackTheme.colors.interactiveSecondary, + 200f, + 0f + ) + } else { + Modifier.background(Color.Transparent) + } Box( - modifier = Modifier.toggleable( - value = selected, - onValueChange = setSelected - ) + modifier = Modifier + .toggleable( + value = selected, + onValueChange = setSelected, + interactionSource = interactionSource, + indication = null + ) + .then(backgroundPressed) + .then(border), ) { Text( text = filter.name, - style = MaterialTheme.typography.caption, + style = MaterialTheme.typography.bodySmall, maxLines = 1, modifier = Modifier.padding( horizontal = 20.dp, @@ -114,3 +164,23 @@ fun FilterChip( } } } + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +private fun FilterDisabledPreview() { + JetsnackTheme { + FilterChip(Filter(name = "Demo", enabled = false), Modifier.padding(4.dp)) + } +} + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +private fun FilterEnabledPreview() { + JetsnackTheme { + FilterChip(Filter(name = "Demo", enabled = true)) + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Gradient.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Gradient.kt index a44e362931..53a61e052a 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Gradient.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Gradient.kt @@ -16,17 +16,19 @@ package com.example.jetsnack.ui.components -import androidx.compose.animation.animateAsState +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -47,13 +49,30 @@ fun Modifier.offsetGradientBackground( offset: Float = 0f ) = background( Brush.horizontalGradient( - colors, + colors = colors, startX = -offset, endX = width - offset, tileMode = TileMode.Mirror ) ) +fun Modifier.offsetGradientBackground( + colors: List<Color>, + width: Density.() -> Float, + offset: Density.() -> Float = { 0f } +) = drawBehind { + val actualOffset = offset() + + drawRect( + Brush.horizontalGradient( + colors = colors, + startX = -actualOffset, + endX = width() - actualOffset, + tileMode = TileMode.Mirror + ) + ) +} + fun Modifier.diagonalGradientBorder( colors: List<Color>, borderSize: Dp = 2.dp, @@ -71,7 +90,10 @@ fun Modifier.fadeInDiagonalGradientBorder( shape: Shape ) = composed { val animatedColors = List(colors.size) { i -> - animateAsState(if (showBorder) colors[i] else colors[i].copy(alpha = 0f)).value + animateColorAsState( + if (showBorder) colors[i] else colors[i].copy(alpha = 0f), + label = "animated color" + ).value } diagonalGradientBorder( colors = animatedColors, diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/GradientTintedIconButton.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/GradientTintedIconButton.kt index 2ec3044b8b..45a32f8381 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/GradientTintedIconButton.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/GradientTintedIconButton.kt @@ -16,31 +16,96 @@ package com.example.jetsnack.ui.components -import androidx.compose.material.Icon -import androidx.compose.material.IconButton +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.example.jetsnack.ui.theme.JetsnackTheme @Composable fun JetsnackGradientTintedIconButton( imageVector: ImageVector, onClick: () -> Unit, + contentDescription: String?, modifier: Modifier = Modifier, colors: List<Color> = JetsnackTheme.colors.interactiveSecondary ) { + val interactionSource = remember { MutableInteractionSource() } + // This should use a layer + srcIn but needs investigation + val border = Modifier.fadeInDiagonalGradientBorder( + showBorder = true, + colors = JetsnackTheme.colors.interactiveSecondary, + shape = CircleShape + ) + val pressed by interactionSource.collectIsPressedAsState() + val background = if (pressed) { + Modifier.offsetGradientBackground(colors, 200f, 0f) + } else { + Modifier.background(JetsnackTheme.colors.uiBackground) + } val blendMode = if (JetsnackTheme.colors.isDark) BlendMode.Darken else BlendMode.Plus - IconButton(onClick = onClick, modifier) { + val modifierColor = if (pressed) { + Modifier.diagonalGradientTint( + colors = listOf( + JetsnackTheme.colors.textSecondary, + JetsnackTheme.colors.textSecondary + ), + blendMode = blendMode + ) + } else { + Modifier.diagonalGradientTint( + colors = colors, + blendMode = blendMode + ) + } + Surface( + modifier = modifier + .clickable( + onClick = onClick, + interactionSource = interactionSource, + indication = null + ) + .clip(CircleShape) + .then(border) + .then(background), + color = Color.Transparent + ) { Icon( imageVector = imageVector, - modifier = Modifier.diagonalGradientTint( - colors = colors, - blendMode = blendMode - ) + contentDescription = contentDescription, + modifier = modifierColor + ) + } +} + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun GradientTintedIconButtonPreview() { + JetsnackTheme { + JetsnackGradientTintedIconButton( + imageVector = Icons.Default.Add, + onClick = {}, + contentDescription = "Demo", + modifier = Modifier.padding(4.dp) ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt index 00bcb470b0..26d3151cc4 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Grid.kt @@ -57,7 +57,7 @@ fun VerticalGrid( val columnY = Array(columns) { 0 } placeables.forEachIndexed { index, placeable -> val column = index % columns - placeable.place( + placeable.placeRelative( x = column * itemWidth, y = columnY[column] ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/QuantitySelector.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/QuantitySelector.kt index dc938c29d2..b2dde9ac0c 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/QuantitySelector.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/QuantitySelector.kt @@ -16,23 +16,26 @@ package com.example.jetsnack.ui.components +import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.ChainStyle -import androidx.compose.foundation.layout.ConstraintLayout -import androidx.compose.foundation.layout.preferredWidthIn -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.ContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AddCircleOutline -import androidx.compose.material.icons.outlined.RemoveCircleOutline +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.jetsnack.R @@ -45,55 +48,48 @@ fun QuantitySelector( increaseItemCount: () -> Unit, modifier: Modifier = Modifier ) { - ConstraintLayout(modifier = modifier) { - val (qty, minus, quantity, plus) = createRefs() - createHorizontalChain(qty, minus, quantity, plus, chainStyle = ChainStyle.Packed) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(R.string.quantity), - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier.constrainAs(qty) { - start.linkTo(parent.start) - linkTo(top = parent.top, bottom = parent.bottom) - } - ) - } + Row(modifier = modifier) { + Text( + text = stringResource(R.string.quantity), + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textSecondary, + fontWeight = FontWeight.Normal, + modifier = Modifier + .padding(end = 18.dp) + .align(Alignment.CenterVertically) + ) JetsnackGradientTintedIconButton( - imageVector = Icons.Outlined.RemoveCircleOutline, + imageVector = Icons.Default.Remove, onClick = decreaseItemCount, - modifier = Modifier.constrainAs(minus) { - centerVerticallyTo(quantity) - linkTo(top = parent.top, bottom = parent.bottom) - } + contentDescription = stringResource(R.string.label_decrease), + modifier = Modifier.align(Alignment.CenterVertically) ) Crossfade( - current = count, + targetState = count, modifier = Modifier - .constrainAs(quantity) { baseline.linkTo(qty.baseline) } + .align(Alignment.CenterVertically) ) { Text( text = "$it", - style = MaterialTheme.typography.subtitle2, + style = MaterialTheme.typography.titleSmall, fontSize = 18.sp, color = JetsnackTheme.colors.textPrimary, textAlign = TextAlign.Center, - modifier = Modifier.preferredWidthIn(min = 24.dp) + modifier = Modifier.widthIn(min = 24.dp) ) } JetsnackGradientTintedIconButton( - imageVector = Icons.Outlined.AddCircleOutline, + imageVector = Icons.Default.Add, onClick = increaseItemCount, - modifier = Modifier.constrainAs(plus) { - end.linkTo(parent.end) - centerVerticallyTo(quantity) - linkTo(top = parent.top, bottom = parent.bottom) - } + contentDescription = stringResource(R.string.label_increase), + modifier = Modifier.align(Alignment.CenterVertically) ) } } -@Preview +@Preview("default") +@Preview("dark theme", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable fun QuantitySelectorPreview() { JetsnackTheme { @@ -102,3 +98,15 @@ fun QuantitySelectorPreview() { } } } + +@Preview("RTL") +@Composable +fun QuantitySelectorPreviewRtl() { + JetsnackTheme { + JetsnackSurface { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + QuantitySelector(1, {}, {}) + } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Scaffold.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Scaffold.kt index b4116ad573..1cc197f736 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Scaffold.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Scaffold.kt @@ -16,66 +16,105 @@ package com.example.jetsnack.ui.components -import androidx.compose.foundation.layout.ColumnScope +import android.content.res.Resources import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.material.DrawerDefaults -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FabPosition -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.ScaffoldState -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.emptyContent +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import com.example.jetsnack.model.SnackbarManager import com.example.jetsnack.ui.theme.JetsnackTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** - * Wrap Material [androidx.compose.material.Scaffold] and set [JetsnackTheme] colors. + * Wrap Material [androidx.compose.material3.Scaffold] and set [JetsnackTheme] colors. */ -@OptIn(ExperimentalMaterialApi::class) @Composable fun JetsnackScaffold( modifier: Modifier = Modifier, - scaffoldState: ScaffoldState = rememberScaffoldState(), - topBar: @Composable (() -> Unit) = emptyContent(), - bottomBar: @Composable (() -> Unit) = emptyContent(), + snackBarHostState: SnackbarHostState = remember { SnackbarHostState() }, + topBar: @Composable (() -> Unit) = {}, + bottomBar: @Composable (() -> Unit) = {}, snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, - floatingActionButton: @Composable (() -> Unit) = emptyContent(), + floatingActionButton: @Composable (() -> Unit) = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, - isFloatingActionButtonDocked: Boolean = false, - drawerContent: @Composable (ColumnScope.() -> Unit)? = null, - drawerShape: Shape = MaterialTheme.shapes.large, - drawerElevation: Dp = DrawerDefaults.Elevation, - drawerBackgroundColor: Color = JetsnackTheme.colors.uiBackground, - drawerContentColor: Color = JetsnackTheme.colors.textSecondary, - drawerScrimColor: Color = JetsnackTheme.colors.uiBorder, backgroundColor: Color = JetsnackTheme.colors.uiBackground, contentColor: Color = JetsnackTheme.colors.textSecondary, - bodyContent: @Composable (PaddingValues) -> Unit + content: @Composable (PaddingValues) -> Unit ) { Scaffold( modifier = modifier, - scaffoldState = scaffoldState, topBar = topBar, bottomBar = bottomBar, - snackbarHost = snackbarHost, + snackbarHost = { + snackbarHost(snackBarHostState) + }, floatingActionButton = floatingActionButton, floatingActionButtonPosition = floatingActionButtonPosition, - isFloatingActionButtonDocked = isFloatingActionButtonDocked, - drawerContent = drawerContent, - drawerShape = drawerShape, - drawerElevation = drawerElevation, - drawerBackgroundColor = drawerBackgroundColor, - drawerContentColor = drawerContentColor, - drawerScrimColor = drawerScrimColor, - backgroundColor = backgroundColor, + containerColor = backgroundColor, contentColor = contentColor, - bodyContent = bodyContent + content = content ) } + +/** + * Remember and creates an instance of [JetsnackScaffoldState] + */ +@Composable +fun rememberJetsnackScaffoldState( + snackBarHostState: SnackbarHostState = remember { SnackbarHostState() }, + snackbarManager: SnackbarManager = SnackbarManager, + resources: Resources = resources(), + coroutineScope: CoroutineScope = rememberCoroutineScope() +): JetsnackScaffoldState = remember(snackBarHostState, snackbarManager, resources, coroutineScope) { + JetsnackScaffoldState(snackBarHostState, snackbarManager, resources, coroutineScope) +} + +/** + * Responsible for holding [ScaffoldState], handles the logic of showing snackbar messages + */ +@Stable +class JetsnackScaffoldState( + val snackBarHostState: SnackbarHostState, + private val snackbarManager: SnackbarManager, + private val resources: Resources, + coroutineScope: CoroutineScope +) { + // Process snackbars coming from SnackbarManager + init { + coroutineScope.launch { + snackbarManager.messages.collect { currentMessages -> + if (currentMessages.isNotEmpty()) { + val message = currentMessages[0] + val text = resources.getText(message.messageId) + // Notify the SnackbarManager so it can remove the current message from the list + snackbarManager.setMessageShown(message.id) + // Display the snackbar on the screen. `showSnackbar` is a function + // that suspends until the snackbar disappears from the screen + snackBarHostState.showSnackbar(text.toString()) + } + } + } + } +} + +/** + * A composable function that returns the [Resources]. It will be recomposed when `Configuration` + * gets updated. + */ +@Composable +@ReadOnlyComposable +private fun resources(): Resources { + LocalConfiguration.current + return LocalContext.current.resources +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snackbar.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snackbar.kt new file mode 100644 index 0000000000..996666f1a6 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snackbar.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import com.example.jetsnack.ui.theme.JetsnackTheme + +/** + * An alternative to [androidx.compose.material3.Snackbar] utilizing + * [com.example.jetsnack.ui.theme.JetsnackColors] + */ +@Composable +fun JetsnackSnackbar( + snackbarData: SnackbarData, + modifier: Modifier = Modifier, + actionOnNewLine: Boolean = false, + shape: Shape = MaterialTheme.shapes.small, + backgroundColor: Color = JetsnackTheme.colors.uiBackground, + contentColor: Color = JetsnackTheme.colors.textSecondary, + actionColor: Color = JetsnackTheme.colors.brand +) { + Snackbar( + snackbarData = snackbarData, + modifier = modifier, + actionOnNewLine = actionOnNewLine, + shape = shape, + containerColor = backgroundColor, + contentColor = contentColor, + actionColor = actionColor + ) +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt index 192d32736e..5db06f3b20 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Snacks.kt @@ -14,10 +14,23 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.example.jetsnack.ui.components -import androidx.compose.foundation.ScrollableRow +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -25,52 +38,63 @@ 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.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredHeightIn -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowForward +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.AmbientDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.example.jetsnack.R import com.example.jetsnack.model.CollectionType import com.example.jetsnack.model.Snack import com.example.jetsnack.model.SnackCollection import com.example.jetsnack.model.snacks +import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope +import com.example.jetsnack.ui.LocalSharedTransitionScope +import com.example.jetsnack.ui.SnackSharedElementKey +import com.example.jetsnack.ui.SnackSharedElementType +import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring +import com.example.jetsnack.ui.snackdetail.snackDetailBoundsTransform import com.example.jetsnack.ui.theme.JetsnackTheme -import dev.chrisbanes.accompanist.coil.CoilImage private val HighlightCardWidth = 170.dp private val HighlightCardPadding = 16.dp - -// The Cards show a gradient which spans 3 cards and scrolls with parallax. -private val gradientWidth - @Composable - get() = with(AmbientDensity.current) { - (3 * (HighlightCardWidth + HighlightCardPadding).toPx()) - } +private val Density.cardWidthWithPaddingPx + get() = (HighlightCardWidth + HighlightCardPadding).toPx() @Composable fun SnackCollection( snackCollection: SnackCollection, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier, index: Int = 0, highlight: Boolean = true @@ -79,12 +103,12 @@ fun SnackCollection( Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .preferredHeightIn(min = 56.dp) + .heightIn(min = 56.dp) .padding(start = 24.dp) ) { Text( text = snackCollection.name, - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.brand, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -97,51 +121,66 @@ fun SnackCollection( modifier = Modifier.align(Alignment.CenterVertically) ) { Icon( - imageVector = Icons.Outlined.ArrowForward, - tint = JetsnackTheme.colors.brand + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + tint = JetsnackTheme.colors.brand, + contentDescription = null ) } } if (highlight && snackCollection.type == CollectionType.Highlight) { - HighlightedSnacks(index, snackCollection.snacks, onSnackClick) + HighlightedSnacks(snackCollection.id, index, snackCollection.snacks, onSnackClick) } else { - Snacks(snackCollection.snacks, onSnackClick) + Snacks(snackCollection.id, snackCollection.snacks, onSnackClick) } } } @Composable private fun HighlightedSnacks( + snackCollectionId: Long, index: Int, snacks: List<Snack>, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier ) { - val scroll = rememberScrollState(0f) - val gradient = when (index % 2) { + val rowState = rememberLazyListState() + val cardWidthWithPaddingPx = with(LocalDensity.current) { cardWidthWithPaddingPx } + + val scrollProvider = { + // Simple calculation of scroll distance for homogenous item types with the same width. + val offsetFromStart = cardWidthWithPaddingPx * rowState.firstVisibleItemIndex + offsetFromStart + rowState.firstVisibleItemScrollOffset + } + + val gradient = when ((index / 2) % 2) { 0 -> JetsnackTheme.colors.gradient6_1 else -> JetsnackTheme.colors.gradient6_2 } - // The Cards show a gradient which spans 3 cards and scrolls with parallax. - val gradientWidth = with(AmbientDensity.current) { - (3 * (HighlightCardWidth + HighlightCardPadding).toPx()) - } - ScrollableRow( - scrollState = scroll, - modifier = modifier + + LazyRow( + state = rowState, + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp) ) { - Spacer(modifier = Modifier.preferredWidth(24.dp)) - snacks.forEachIndexed { index, snack -> - HighlightSnackItem(snack, onSnackClick, index, gradient, gradientWidth, scroll.value) - Spacer(modifier = Modifier.preferredWidth(16.dp)) + itemsIndexed(snacks) { index, snack -> + HighlightSnackItem( + snackCollectionId = snackCollectionId, + snack = snack, + onSnackClick = onSnackClick, + index = index, + gradient = gradient, + scrollProvider = scrollProvider + ) } } } @Composable private fun Snacks( + snackCollectionId: Long, snacks: List<Snack>, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier ) { LazyRow( @@ -149,7 +188,7 @@ private fun Snacks( contentPadding = PaddingValues(start = 12.dp, end = 12.dp) ) { items(snacks) { snack -> - SnackItem(snack, onSnackClick) + SnackItem(snack, snackCollectionId, onSnackClick) } } } @@ -157,7 +196,8 @@ private fun Snacks( @Composable fun SnackItem( snack: Snack, - onSnackClick: (Long) -> Unit, + snackCollectionId: Long, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier ) { JetsnackSurface( @@ -167,142 +207,314 @@ fun SnackItem( end = 4.dp, bottom = 8.dp ) + ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .clickable(onClick = { onSnackClick(snack.id) }) - .padding(8.dp) - ) { - SnackImage( - imageUrl = snack.imageUrl, - elevation = 4.dp, - modifier = Modifier.preferredSize(120.dp) - ) - Text( - text = snack.name, - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier.padding(top = 8.dp) - ) + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No sharedTransitionScope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No animatedVisibilityScope found") + + with(sharedTransitionScope) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clickable(onClick = { + onSnackClick(snack.id, snackCollectionId.toString()) + }) + .padding(8.dp) + ) { + SnackImage( + imageRes = snack.imageRes, + elevation = 1.dp, + contentDescription = null, + modifier = Modifier + .size(120.dp) + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Image + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform + ) + ) + Text( + text = snack.name, + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textSecondary, + modifier = Modifier + .padding(top = 8.dp) + .wrapContentWidth() + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Title + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds(), + boundsTransform = snackDetailBoundsTransform + ) + ) + } } } } @Composable private fun HighlightSnackItem( + snackCollectionId: Long, snack: Snack, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, index: Int, gradient: List<Color>, - gradientWidth: Float, - scroll: Float, + scrollProvider: () -> Float, modifier: Modifier = Modifier ) { - val left = index * with(AmbientDensity.current) { - (HighlightCardWidth + HighlightCardPadding).toPx() - } - JetsnackCard( - elevation = 4.dp, - modifier = modifier - .preferredSize( - width = 170.dp, - height = 250.dp - ) - .padding(bottom = 16.dp) - ) { - Column( - modifier = Modifier - .clickable(onClick = { onSnackClick(snack.id) }) - .fillMaxSize() + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No Scope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No Scope found") + with(sharedTransitionScope) { + val roundedCornerAnimation by animatedVisibilityScope.transition + .animateDp(label = "rounded corner") { enterExit: EnterExitState -> + when (enterExit) { + EnterExitState.PreEnter -> 0.dp + EnterExitState.Visible -> 20.dp + EnterExitState.PostExit -> 20.dp + } + } + JetsnackCard( + elevation = 0.dp, + shape = RoundedCornerShape(roundedCornerAnimation), + modifier = modifier + .padding(bottom = 16.dp) + .sharedBounds( + sharedContentState = rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Bounds + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + clipInOverlayDuringTransition = OverlayClip( + RoundedCornerShape( + roundedCornerAnimation + ) + ), + enter = fadeIn(), + exit = fadeOut() + ) + .size( + width = HighlightCardWidth, + height = 250.dp + ) + .border( + 1.dp, + JetsnackTheme.colors.uiBorder.copy(alpha = 0.12f), + RoundedCornerShape(roundedCornerAnimation) + ) + ) { - Box( + Column( modifier = Modifier - .preferredHeight(160.dp) - .fillMaxWidth() + .clickable(onClick = { + onSnackClick( + snack.id, + snackCollectionId.toString() + ) + }) + .fillMaxSize() + ) { - val gradientOffset = left - (scroll / 3f) Box( modifier = Modifier - .preferredHeight(100.dp) + .height(160.dp) .fillMaxWidth() - .offsetGradientBackground(gradient, gradientWidth, gradientOffset) + ) { + Box( + modifier = Modifier + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Background + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds() + ) + .height(100.dp) + .fillMaxWidth() + .offsetGradientBackground( + colors = gradient, + width = { + // The Cards show a gradient which spans 6 cards and + // scrolls with parallax. + 6 * cardWidthWithPaddingPx + }, + offset = { + val left = index * cardWidthWithPaddingPx + val gradientOffset = left - (scrollProvider() / 3f) + gradientOffset + } + ) + ) + + SnackImage( + imageRes = snack.imageRes, + contentDescription = null, + modifier = Modifier + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Image + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + exit = fadeOut(nonSpatialExpressiveSpring()), + enter = fadeIn(nonSpatialExpressiveSpring()), + boundsTransform = snackDetailBoundsTransform + ) + .align(Alignment.BottomCenter) + .size(120.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = snack.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + color = JetsnackTheme.colors.textSecondary, + modifier = Modifier + .padding(horizontal = 16.dp) + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Title + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + boundsTransform = snackDetailBoundsTransform, + resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds() + ) + .wrapContentWidth() ) - SnackImage( - imageUrl = snack.imageUrl, + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = snack.tagline, + style = MaterialTheme.typography.bodyLarge, + color = JetsnackTheme.colors.textHelp, modifier = Modifier - .preferredSize(120.dp) - .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp) + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = snackCollectionId.toString(), + type = SnackSharedElementType.Tagline + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + boundsTransform = snackDetailBoundsTransform, + resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds() + ) + .wrapContentWidth() ) } - Spacer(modifier = Modifier.preferredHeight(8.dp)) - Text( - text = snack.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.h6, - color = JetsnackTheme.colors.textSecondary, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.preferredHeight(4.dp)) - Text( - text = snack.tagline, - style = MaterialTheme.typography.body1, - color = JetsnackTheme.colors.textHelp, - modifier = Modifier.padding(horizontal = 16.dp) - ) } } } +@Composable +fun debugPlaceholder(@DrawableRes debugPreview: Int) = + if (LocalInspectionMode.current) { + painterResource(id = debugPreview) + } else { + null + } + @Composable fun SnackImage( - imageUrl: String, + @DrawableRes + imageRes: Int, + contentDescription: String?, modifier: Modifier = Modifier, elevation: Dp = 0.dp ) { JetsnackSurface( - color = Color.LightGray, elevation = elevation, shape = CircleShape, modifier = modifier ) { - CoilImage( - data = imageUrl, + + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageRes) + .crossfade(true) + .build(), + placeholder = debugPlaceholder(debugPreview = R.drawable.placeholder), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() ) } } -@Preview("Highlight snack card") +@Preview("default") +@Preview("dark theme", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable fun SnackCardPreview() { - JetsnackTheme { - val snack = snacks.first() + val snack = snacks.first() + JetsnackPreviewWrapper { HighlightSnackItem( + snackCollectionId = 1, snack = snack, - onSnackClick = { }, + onSnackClick = { _, _ -> }, index = 0, gradient = JetsnackTheme.colors.gradient6_1, - gradientWidth = gradientWidth, - scroll = 0f + scrollProvider = { 0f } ) } } -@Preview("Highlight snack card • Dark Theme") @Composable -fun SnackCardDarkPreview() { - JetsnackTheme(darkTheme = true) { - val snack = snacks.first() - HighlightSnackItem( - snack = snack, - onSnackClick = { }, - index = 0, - gradient = JetsnackTheme.colors.gradient6_1, - gradientWidth = gradientWidth, - scroll = 0f - ) +fun JetsnackPreviewWrapper(content: @Composable () -> Unit) { + JetsnackTheme { + SharedTransitionLayout { + AnimatedVisibility(visible = true) { + CompositionLocalProvider( + LocalSharedTransitionScope provides this@SharedTransitionLayout, + LocalNavAnimatedVisibilityScope provides this + ) { + content() + } + } + } } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt index b19b4755a8..f109fe77a5 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/components/Surface.kt @@ -20,9 +20,9 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box -import androidx.compose.material.AmbientContentColor +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow @@ -37,7 +37,7 @@ import com.example.jetsnack.ui.theme.JetsnackTheme import kotlin.math.ln /** - * An alternative to [androidx.compose.material.Surface] utilizing + * An alternative to [androidx.compose.material3.Surface] utilizing * [com.example.jetsnack.ui.theme.JetsnackColors] */ @Composable @@ -51,7 +51,8 @@ fun JetsnackSurface( content: @Composable () -> Unit ) { Box( - modifier = modifier.shadow(elevation = elevation, shape = shape, clip = false) + modifier = modifier + .shadow(elevation = elevation, shape = shape, clip = false) .zIndex(elevation.value) .then(if (border != null) Modifier.border(border, shape) else Modifier) .background( @@ -60,7 +61,7 @@ fun JetsnackSurface( ) .clip(shape) ) { - Providers(AmbientContentColor provides contentColor, content = content) + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt index 5afa3985b1..691998ceba 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/DestinationBar.kt @@ -14,56 +14,105 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.example.jetsnack.ui.home +import android.content.res.Configuration +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Column -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import com.example.jetsnack.R +import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope +import com.example.jetsnack.ui.LocalSharedTransitionScope import com.example.jetsnack.ui.components.JetsnackDivider +import com.example.jetsnack.ui.components.JetsnackPreviewWrapper +import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring import com.example.jetsnack.ui.theme.AlphaNearOpaque import com.example.jetsnack.ui.theme.JetsnackTheme -import dev.chrisbanes.accompanist.insets.statusBarsPadding +@OptIn(ExperimentalMaterial3Api::class) @Composable fun DestinationBar(modifier: Modifier = Modifier) { - Column(modifier = modifier.statusBarsPadding()) { - TopAppBar( - backgroundColor = JetsnackTheme.colors.uiBackground.copy(alpha = AlphaNearOpaque), - contentColor = JetsnackTheme.colors.textSecondary, - elevation = 0.dp - ) { - Text( - text = "Delivery to 1600 Amphitheater Way", - style = MaterialTheme.typography.subtitle1, - color = JetsnackTheme.colors.textSecondary, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) - IconButton( - onClick = { /* todo */ }, - modifier = Modifier.align(Alignment.CenterVertically) + val sharedElementScope = + LocalSharedTransitionScope.current ?: throw IllegalStateException("No shared element scope") + val navAnimatedScope = + LocalNavAnimatedVisibilityScope.current ?: throw IllegalStateException("No nav scope") + with(sharedElementScope) { + with(navAnimatedScope) { + Column( + modifier = modifier + .renderInSharedTransitionScopeOverlay() + .animateEnterExit( + enter = slideInVertically(spatialExpressiveSpring()) { -it * 2 }, + exit = slideOutVertically(spatialExpressiveSpring()) { -it * 2 } + ) ) { - Icon( - imageVector = Icons.Outlined.ExpandMore, - tint = JetsnackTheme.colors.brand + TopAppBar( + windowInsets = WindowInsets(0, 0, 0, 0), + title = { + Row { + Text( + text = "Delivery to 1600 Amphitheater Way", + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.textSecondary, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) + IconButton( + onClick = { /* todo */ }, + modifier = Modifier.align(Alignment.CenterVertically) + ) { + Icon( + imageVector = Icons.Outlined.ExpandMore, + tint = JetsnackTheme.colors.brand, + contentDescription = + stringResource(R.string.label_select_delivery) + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors().copy( + containerColor = JetsnackTheme.colors.uiBackground + .copy(alpha = AlphaNearOpaque), + titleContentColor = JetsnackTheme.colors.textSecondary + ), ) + JetsnackDivider() } } - JetsnackDivider() + } +} + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +fun PreviewDestinationBar() { + JetsnackPreviewWrapper { + DestinationBar() } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt index 4e702fa647..d18c06e1f2 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Feed.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,14 +14,31 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.example.jetsnack.ui.home +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -33,11 +50,10 @@ import com.example.jetsnack.ui.components.JetsnackDivider import com.example.jetsnack.ui.components.JetsnackSurface import com.example.jetsnack.ui.components.SnackCollection import com.example.jetsnack.ui.theme.JetsnackTheme -import dev.chrisbanes.accompanist.insets.statusBarsHeight @Composable fun Feed( - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier ) { val snackCollections = remember { SnackRepo.getSnacks() } @@ -54,13 +70,33 @@ fun Feed( private fun Feed( snackCollections: List<SnackCollection>, filters: List<Filter>, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier ) { JetsnackSurface(modifier = modifier.fillMaxSize()) { - Box { - SnackCollectionList(snackCollections, filters, onSnackClick) - DestinationBar() + var filtersVisible by remember { + mutableStateOf(false) + } + SharedTransitionLayout { + Box { + SnackCollectionList( + snackCollections, + filters, + filtersVisible = filtersVisible, + onFiltersSelected = { + filtersVisible = true + }, + sharedTransitionScope = this@SharedTransitionLayout, + onSnackClick = onSnackClick + ) + DestinationBar() + AnimatedVisibility(filtersVisible, enter = fadeIn(), exit = fadeOut()) { + FilterScreen( + animatedVisibilityScope = this@AnimatedVisibility, + sharedTransitionScope = this@SharedTransitionLayout + ) { filtersVisible = false } + } + } } } } @@ -69,18 +105,31 @@ private fun Feed( private fun SnackCollectionList( snackCollections: List<SnackCollection>, filters: List<Filter>, - onSnackClick: (Long) -> Unit, + filtersVisible: Boolean, + onFiltersSelected: () -> Unit, + onSnackClick: (Long, String) -> Unit, + sharedTransitionScope: SharedTransitionScope, modifier: Modifier = Modifier ) { - LazyColumn(modifier) { + LazyColumn(modifier = modifier) { item { - Spacer(Modifier.statusBarsHeight(additional = 56.dp)) - FilterBar(filters) + Spacer( + Modifier.windowInsetsTopHeight( + WindowInsets.statusBars.add(WindowInsets(top = 56.dp)) + ) + ) + FilterBar( + filters, + sharedTransitionScope = sharedTransitionScope, + filterScreenVisible = filtersVisible, + onShowFilters = onFiltersSelected + ) } itemsIndexed(snackCollections) { index, snackCollection -> if (index > 0) { JetsnackDivider(thickness = 2.dp) } + SnackCollection( snackCollection = snackCollection, onSnackClick = onSnackClick, @@ -90,18 +139,12 @@ private fun SnackCollectionList( } } -@Preview("Home") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable fun HomePreview() { JetsnackTheme { - Feed(onSnackClick = { }) - } -} - -@Preview("Home • Dark Theme") -@Composable -fun HomeDarkPreview() { - JetsnackTheme(darkTheme = true) { - Feed(onSnackClick = { }) + Feed(onSnackClick = { _, _ -> }) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/FilterScreen.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/FilterScreen.kt new file mode 100644 index 0000000000..e64fd2c955 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/FilterScreen.kt @@ -0,0 +1,336 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class) + +package com.example.jetsnack.ui.home + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetsnack.R +import com.example.jetsnack.model.Filter +import com.example.jetsnack.model.SnackRepo +import com.example.jetsnack.ui.FilterSharedElementKey +import com.example.jetsnack.ui.components.FilterChip +import com.example.jetsnack.ui.theme.JetsnackTheme + +@Composable +fun FilterScreen( + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + onDismiss: () -> Unit +) { + var sortState by remember { mutableStateOf(SnackRepo.getSortDefault()) } + var maxCalories by remember { mutableFloatStateOf(0f) } + val defaultFilter = SnackRepo.getSortDefault() + + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + // capture click + } + ) { + val priceFilters = remember { SnackRepo.getPriceFilters() } + val categoryFilters = remember { SnackRepo.getCategoryFilters() } + val lifeStyleFilters = remember { SnackRepo.getLifeStyleFilters() } + Spacer( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + onDismiss() + } + ) + with(sharedTransitionScope) { + Column( + Modifier + .padding(16.dp) + .align(Alignment.Center) + .clip(MaterialTheme.shapes.medium) + .sharedBounds( + rememberSharedContentState(FilterSharedElementKey), + animatedVisibilityScope = animatedVisibilityScope, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + clipInOverlayDuringTransition = OverlayClip(MaterialTheme.shapes.medium) + ) + .wrapContentSize() + .heightIn(max = 450.dp) + .verticalScroll(rememberScrollState()) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { } + .background(JetsnackTheme.colors.uiFloated) + .padding(horizontal = 24.dp, vertical = 16.dp) + .skipToLookaheadSize(), + ) { + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.close) + ) + } + Text( + text = stringResource(id = R.string.label_filters), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(top = 8.dp, end = 48.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) + val resetEnabled = sortState != defaultFilter + + IconButton( + onClick = { /* TODO: Open search */ }, + enabled = resetEnabled + ) { + val fontWeight = if (resetEnabled) { + FontWeight.Bold + } else { + FontWeight.Normal + } + + Text( + text = stringResource(id = R.string.reset), + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + color = JetsnackTheme.colors.uiBackground + .copy(alpha = if (!resetEnabled) 0.38f else 1f) + ) + } + } + + SortFiltersSection( + sortState = sortState, + onFilterChange = { filter -> + sortState = filter.name + } + ) + FilterChipSection( + title = stringResource(id = R.string.price), + filters = priceFilters + ) + FilterChipSection( + title = stringResource(id = R.string.category), + filters = categoryFilters + ) + + MaxCalories( + sliderPosition = maxCalories, + onValueChanged = { newValue -> + maxCalories = newValue + } + ) + FilterChipSection( + title = stringResource(id = R.string.lifestyle), + filters = lifeStyleFilters + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FilterChipSection(title: String, filters: List<Filter>) { + FilterTitle(text = title) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 16.dp) + .padding(horizontal = 4.dp) + ) { + filters.forEach { filter -> + FilterChip( + filter = filter, + modifier = Modifier.padding(end = 4.dp, bottom = 8.dp) + ) + } + } +} + +@Composable +fun SortFiltersSection(sortState: String, onFilterChange: (Filter) -> Unit) { + FilterTitle(text = stringResource(id = R.string.sort)) + Column(Modifier.padding(bottom = 24.dp)) { + SortFilters( + sortState = sortState, + onChanged = onFilterChange + ) + } +} + +@Composable +fun SortFilters( + sortFilters: List<Filter> = SnackRepo.getSortFilters(), + sortState: String, + onChanged: (Filter) -> Unit +) { + + sortFilters.forEach { filter -> + SortOption( + text = filter.name, + icon = filter.icon, + selected = sortState == filter.name, + onClickOption = { + onChanged(filter) + } + ) + } +} + +@Composable +fun MaxCalories(sliderPosition: Float, onValueChanged: (Float) -> Unit) { + FlowRow { + FilterTitle(text = stringResource(id = R.string.max_calories)) + Text( + text = stringResource(id = R.string.per_serving), + style = MaterialTheme.typography.bodyMedium, + color = JetsnackTheme.colors.brand, + modifier = Modifier.padding(top = 5.dp, start = 10.dp) + ) + } + Slider( + value = sliderPosition, + onValueChange = { newValue -> + onValueChanged(newValue) + }, + valueRange = 0f..300f, + steps = 5, + modifier = Modifier + .fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = JetsnackTheme.colors.brand, + activeTrackColor = JetsnackTheme.colors.brand, + inactiveTrackColor = JetsnackTheme.colors.iconInteractive + ) + ) +} + +@Composable +fun FilterTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleLarge, + color = JetsnackTheme.colors.brand, + modifier = Modifier.padding(bottom = 8.dp) + ) +} + +@Composable +fun SortOption( + text: String, + icon: ImageVector?, + onClickOption: () -> Unit, + selected: Boolean +) { + Row( + modifier = Modifier + .padding(top = 14.dp) + .selectable(selected) { onClickOption() } + ) { + if (icon != null) { + Icon(imageVector = icon, contentDescription = null) + } + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(start = 10.dp) + .weight(1f) + ) + if (selected) { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = null, + tint = JetsnackTheme.colors.brand + ) + } + } +} + +@Preview("filter screen") +@Composable +fun FilterScreenPreview() { + JetsnackTheme { + SharedTransitionLayout { + AnimatedVisibility(true) { + FilterScreen( + animatedVisibilityScope = this, + sharedTransitionScope = this@SharedTransitionLayout, + onDismiss = {} + ) + } + } + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt index 9cd6109de4..43dc70a9b3 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Home.kt @@ -18,36 +18,40 @@ package com.example.jetsnack.ui.home import androidx.annotation.FloatRange import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedFloatModel -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateAsState -import androidx.compose.animation.animatedFloat +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.animateAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.ShoppingCart +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.onCommit import androidx.compose.runtime.remember -import androidx.compose.runtime.savedinstancestate.savedInstanceState -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -60,107 +64,171 @@ import androidx.compose.ui.layout.MeasureResult import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.platform.AmbientAnimationClock -import androidx.compose.ui.platform.AmbientConfiguration +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import androidx.core.os.ConfigurationCompat +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable import com.example.jetsnack.R -import com.example.jetsnack.ui.components.JetsnackScaffold +import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope import com.example.jetsnack.ui.components.JetsnackSurface import com.example.jetsnack.ui.home.cart.Cart import com.example.jetsnack.ui.home.search.Search +import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring +import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring import com.example.jetsnack.ui.theme.JetsnackTheme -import dev.chrisbanes.accompanist.insets.navigationBarsPadding +import java.util.Locale -@Composable -fun Home(onSnackSelected: (Long) -> Unit) { - val (currentSection, setCurrentSection) = savedInstanceState { HomeSections.Feed } - val navItems = HomeSections.values().toList() - JetsnackScaffold( - bottomBar = { - JetsnackBottomNav( - currentSection = currentSection, - onSectionSelected = setCurrentSection, - items = navItems - ) - } - ) { innerPadding -> - val modifier = Modifier.padding(innerPadding) - Crossfade(currentSection) { section -> - when (section) { - HomeSections.Feed -> Feed( - onSnackClick = onSnackSelected, - modifier = modifier - ) - HomeSections.Search -> Search(onSnackSelected, modifier) - HomeSections.Cart -> Cart(onSnackSelected, modifier) - HomeSections.Profile -> Profile(modifier) - } +fun NavGraphBuilder.composableWithCompositionLocal( + route: String, + arguments: List<NamedNavArgument> = emptyList(), + deepLinks: List<NavDeepLink> = emptyList(), + enterTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition? + )? = { + fadeIn(nonSpatialExpressiveSpring()) + }, + exitTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition? + )? = { + fadeOut(nonSpatialExpressiveSpring()) + }, + popEnterTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition? + )? = + enterTransition, + popExitTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition? + )? = + exitTransition, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit +) { + composable( + route, + arguments, + deepLinks, + enterTransition, + exitTransition, + popEnterTransition, + popExitTransition + ) { + CompositionLocalProvider( + LocalNavAnimatedVisibilityScope provides this@composable + ) { + content(it) } } } +fun NavGraphBuilder.addHomeGraph( + onSnackSelected: (Long, String, NavBackStackEntry) -> Unit, + modifier: Modifier = Modifier +) { + composable(HomeSections.FEED.route) { from -> + Feed( + onSnackClick = { id, origin -> onSnackSelected(id, origin, from) }, + modifier + ) + } + composable(HomeSections.SEARCH.route) { from -> + Search( + onSnackClick = { id, origin -> onSnackSelected(id, origin, from) }, + modifier + ) + } + composable(HomeSections.CART.route) { from -> + Cart( + onSnackClick = { id, origin -> onSnackSelected(id, origin, from) }, + modifier + ) + } + composable(HomeSections.PROFILE.route) { + Profile(modifier) + } +} + +enum class HomeSections( + @StringRes val title: Int, + val icon: ImageVector, + val route: String +) { + FEED(R.string.home_feed, Icons.Outlined.Home, "home/feed"), + SEARCH(R.string.home_search, Icons.Outlined.Search, "home/search"), + CART(R.string.home_cart, Icons.Outlined.ShoppingCart, "home/cart"), + PROFILE(R.string.home_profile, Icons.Outlined.AccountCircle, "home/profile") +} + @Composable -private fun JetsnackBottomNav( - currentSection: HomeSections, - onSectionSelected: (HomeSections) -> Unit, - items: List<HomeSections>, +fun JetsnackBottomBar( + tabs: Array<HomeSections>, + currentRoute: String, + navigateToRoute: (String) -> Unit, + modifier: Modifier = Modifier, color: Color = JetsnackTheme.colors.iconPrimary, contentColor: Color = JetsnackTheme.colors.iconInteractive ) { + val routes = remember { tabs.map { it.route } } + val currentSection = tabs.first { it.route == currentRoute } + JetsnackSurface( + modifier = modifier, color = color, contentColor = contentColor ) { - val springSpec = remember { - SpringSpec<Float>( - // Determined experimentally - stiffness = 800f, - dampingRatio = 0.8f - ) - } + val springSpec = spatialExpressiveSpring<Float>() JetsnackBottomNavLayout( selectedIndex = currentSection.ordinal, - itemCount = items.size, + itemCount = routes.size, indicator = { JetsnackBottomNavIndicator() }, animSpec = springSpec, - modifier = Modifier.navigationBarsPadding(left = false, right = false) + modifier = Modifier.navigationBarsPadding() ) { - items.forEach { section -> + val configuration = LocalConfiguration.current + val currentLocale: Locale = + ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault() + + tabs.forEach { section -> val selected = section == currentSection - val tint by animateAsState( + val tint by animateColorAsState( if (selected) { JetsnackTheme.colors.iconInteractive } else { JetsnackTheme.colors.iconInteractiveInactive - } + }, + label = "tint" ) + val text = stringResource(section.title).uppercase(currentLocale) + JetsnackBottomNavigationItem( icon = { Icon( imageVector = section.icon, - tint = tint + tint = tint, + contentDescription = text ) }, text = { Text( - text = stringResource(section.title).toUpperCase( - ConfigurationCompat.getLocales( - AmbientConfiguration.current - ).get(0) - ), + text = text, color = tint, - style = MaterialTheme.typography.button, + style = MaterialTheme.typography.labelLarge, maxLines = 1 ) }, selected = selected, - onSelected = { onSectionSelected(section) }, + onSelected = { navigateToRoute(section.route) }, animSpec = springSpec, modifier = BottomNavigationItemPadding .clip(BottomNavIndicatorShape) @@ -180,27 +248,27 @@ private fun JetsnackBottomNavLayout( content: @Composable () -> Unit ) { // Track how "selected" each item is [0, 1] - val clock = AmbientAnimationClock.current val selectionFractions = remember(itemCount) { List(itemCount) { i -> - AnimatedFloatModel(if (i == selectedIndex) 1f else 0f, clock) + Animatable(if (i == selectedIndex) 1f else 0f) } } - - // When selection changes, animate the selection fractions - onCommit(selectedIndex) { - selectionFractions.forEachIndexed { index, selectionFraction -> - val target = if (index == selectedIndex) 1f else 0f - if (selectionFraction.targetValue != target) { - selectionFraction.animateTo(target, animSpec) - } + selectionFractions.forEachIndexed { index, selectionFraction -> + val target = if (index == selectedIndex) 1f else 0f + LaunchedEffect(target, animSpec) { + selectionFraction.animateTo(target, animSpec) } } + // Animate the position of the indicator - val indicatorLeft = animatedFloat(0f) + val indicatorIndex = remember { Animatable(0f) } + val targetIndicatorIndex = selectedIndex.toFloat() + LaunchedEffect(targetIndicatorIndex) { + indicatorIndex.animateTo(targetIndicatorIndex, animSpec) + } Layout( - modifier = modifier.preferredHeight(BottomNavHeight), + modifier = modifier.height(BottomNavHeight), content = { content() Box(Modifier.layoutId("indicator"), content = indicator) @@ -210,7 +278,7 @@ private fun JetsnackBottomNavLayout( // Divide the width into n+1 slots and give the selected item 2 slots val unselectedWidth = constraints.maxWidth / (itemCount + 1) - val selectedWidth = constraints.maxWidth - (itemCount - 1) * unselectedWidth + val selectedWidth = 2 * unselectedWidth val indicatorMeasurable = measurables.first { it.layoutId == "indicator" } val itemPlaceables = measurables @@ -232,20 +300,15 @@ private fun JetsnackBottomNavLayout( ) ) - // Animate the indicator position - val targetIndicatorLeft = selectedIndex * unselectedWidth.toFloat() - if (indicatorLeft.targetValue != targetIndicatorLeft) { - indicatorLeft.animateTo(targetIndicatorLeft, animSpec) - } - layout( width = constraints.maxWidth, height = itemPlaceables.maxByOrNull { it.height }?.height ?: 0 ) { - indicatorPlaceable.place(x = indicatorLeft.value.toInt(), y = 0) + val indicatorLeft = indicatorIndex.value * unselectedWidth + indicatorPlaceable.placeRelative(x = indicatorLeft.toInt(), y = 0) var x = 0 itemPlaceables.forEach { placeable -> - placeable.place(x = x, y = 0) + placeable.placeRelative(x = x, y = 0) x += placeable.width } } @@ -261,34 +324,42 @@ fun JetsnackBottomNavigationItem( animSpec: AnimationSpec<Float>, modifier: Modifier = Modifier ) { - Box( - modifier = modifier.selectable(selected = selected, onClick = onSelected), - contentAlignment = Alignment.Center - ) { - // Animate the icon/text positions within the item based on selection - val animationProgress by animateAsState(if (selected) 1f else 0f, animSpec) - JetsnackBottomNavItemLayout( - icon = icon, - text = text, - animationProgress = animationProgress - ) - } + // Animate the icon/text positions within the item based on selection + val animationProgress by animateFloatAsState( + if (selected) 1f else 0f, animSpec, + label = "animation progress" + ) + JetsnackBottomNavItemLayout( + icon = icon, + text = text, + animationProgress = animationProgress, + modifier = modifier + .selectable(selected = selected, onClick = onSelected) + .wrapContentSize() + ) } @Composable private fun JetsnackBottomNavItemLayout( icon: @Composable BoxScope.() -> Unit, text: @Composable BoxScope.() -> Unit, - @FloatRange(from = 0.0, to = 1.0) animationProgress: Float + @FloatRange(from = 0.0, to = 1.0) animationProgress: Float, + modifier: Modifier = Modifier ) { Layout( + modifier = modifier, content = { - Box(Modifier.layoutId("icon"), content = icon) + Box( + modifier = Modifier + .layoutId("icon") + .padding(horizontal = TextIconSpacing), + content = icon + ) val scale = lerp(0.6f, 1f, animationProgress) Box( modifier = Modifier .layoutId("text") - .padding(start = TextIconSpacing) + .padding(horizontal = TextIconSpacing) .graphicsLayer { alpha = animationProgress scaleX = scale @@ -327,9 +398,9 @@ private fun MeasureScope.placeTextAndIcon( val textX = iconX + iconPlaceable.width return layout(width, height) { - iconPlaceable.place(iconX.toInt(), iconY) + iconPlaceable.placeRelative(iconX.toInt(), iconY) if (animationProgress != 0f) { - textPlaceable.place(textX.toInt(), textY) + textPlaceable.placeRelative(textX.toInt(), textY) } } } @@ -348,30 +419,20 @@ private fun JetsnackBottomNavIndicator( ) } -private val TextIconSpacing = 4.dp +private val TextIconSpacing = 2.dp private val BottomNavHeight = 56.dp private val BottomNavLabelTransformOrigin = TransformOrigin(0f, 0.5f) private val BottomNavIndicatorShape = RoundedCornerShape(percent = 50) private val BottomNavigationItemPadding = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) -private enum class HomeSections( - @StringRes val title: Int, - val icon: ImageVector -) { - Feed(R.string.home_feed, Icons.Outlined.Home), - Search(R.string.home_search, Icons.Outlined.Search), - Cart(R.string.home_cart, Icons.Outlined.ShoppingCart), - Profile(R.string.home_profile, Icons.Outlined.AccountCircle) -} - @Preview @Composable -private fun JsetsnackBottomNavPreview() { +private fun JetsnackBottomNavPreview() { JetsnackTheme { - JetsnackBottomNav( - currentSection = HomeSections.Feed, - onSectionSelected = { }, - items = HomeSections.values().toList() + JetsnackBottomBar( + tabs = HomeSections.entries.toTypedArray(), + currentRoute = "home/feed", + navigateToRoute = { } ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Profile.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Profile.kt index 63aba7ab96..c425d5bb42 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Profile.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/Profile.kt @@ -16,20 +16,66 @@ package com.example.jetsnack.ui.home +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.example.jetsnack.R +import com.example.jetsnack.ui.theme.JetsnackTheme @Composable -fun Profile(modifier: Modifier = Modifier) { - Text( - text = stringResource(R.string.home_profile), +fun Profile( + modifier: Modifier = Modifier +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxSize() .wrapContentSize() - ) + .padding(24.dp) + ) { + Image( + painterResource(R.drawable.empty_state_search), + contentDescription = null + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.work_in_progress), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.grab_beverage), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) +@Composable +fun ProfilePreview() { + JetsnackTheme { + Profile() + } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/Cart.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/Cart.kt index e01003a840..3fd93f68e3 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/Cart.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/Cart.kt @@ -16,44 +16,61 @@ package com.example.jetsnack.ui.home.cart +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ChainStyle +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ConstraintLayout import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredHeightIn -import androidx.compose.foundation.layout.preferredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.LastBaseline -import androidx.compose.ui.platform.AmbientContext +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.viewModel +import androidx.constraintlayout.compose.ChainStyle +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.example.jetsnack.R import com.example.jetsnack.model.OrderLine import com.example.jetsnack.model.SnackCollection @@ -65,18 +82,20 @@ import com.example.jetsnack.ui.components.QuantitySelector import com.example.jetsnack.ui.components.SnackCollection import com.example.jetsnack.ui.components.SnackImage import com.example.jetsnack.ui.home.DestinationBar +import com.example.jetsnack.ui.snackdetail.nonSpatialExpressiveSpring +import com.example.jetsnack.ui.snackdetail.spatialExpressiveSpring import com.example.jetsnack.ui.theme.AlphaNearOpaque import com.example.jetsnack.ui.theme.JetsnackTheme import com.example.jetsnack.ui.utils.formatPrice -import dev.chrisbanes.accompanist.insets.statusBarsHeight +import kotlin.math.roundToInt @Composable fun Cart( - onSnackClick: (Long) -> Unit, - modifier: Modifier = Modifier + onSnackClick: (Long, String) -> Unit, + modifier: Modifier = Modifier, + viewModel: CartViewModel = viewModel(factory = CartViewModel.provideFactory()) ) { - val viewModel: CartViewModel = viewModel() - val orderLines by viewModel.orderLines.collectAsState() + val orderLines by viewModel.orderLines.collectAsStateWithLifecycle() val inspiredByCart = remember { SnackRepo.getInspiredByCart() } Cart( orderLines = orderLines, @@ -96,11 +115,11 @@ fun Cart( increaseItemCount: (Long) -> Unit, decreaseItemCount: (Long) -> Unit, inspiredByCart: SnackCollection, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier ) { JetsnackSurface(modifier = modifier.fillMaxSize()) { - Box { + Box(modifier = Modifier.fillMaxSize()) { CartContent( orderLines = orderLines, removeSnack = removeSnack, @@ -123,48 +142,75 @@ private fun CartContent( increaseItemCount: (Long) -> Unit, decreaseItemCount: (Long) -> Unit, inspiredByCart: SnackCollection, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier ) { - val resources = AmbientContext.current.resources + val resources = LocalContext.current.resources val snackCountFormattedString = remember(orderLines.size, resources) { resources.getQuantityString( R.plurals.cart_order_count, orderLines.size, orderLines.size ) } + val itemAnimationSpecFade = nonSpatialExpressiveSpring<Float>() + val itemPlacementSpec = spatialExpressiveSpring<IntOffset>() LazyColumn(modifier) { - item { - Spacer(Modifier.statusBarsHeight(additional = 56.dp)) + item(key = "title") { + Spacer( + Modifier.windowInsetsTopHeight( + WindowInsets.statusBars.add(WindowInsets(top = 56.dp)) + ) + ) Text( text = stringResource(R.string.cart_order_header, snackCountFormattedString), - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.brand, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier - .preferredHeightIn(min = 56.dp) + .heightIn(min = 56.dp) .padding(horizontal = 24.dp, vertical = 4.dp) .wrapContentHeight() ) } - items(orderLines) { orderLine -> - CartItem( - orderLine = orderLine, - removeSnack = removeSnack, - increaseItemCount = increaseItemCount, - decreaseItemCount = decreaseItemCount, - onSnackClick = onSnackClick - ) + items(orderLines, key = { it.snack.id }) { orderLine -> + SwipeDismissItem( + modifier = Modifier.animateItem( + fadeInSpec = itemAnimationSpecFade, + fadeOutSpec = itemAnimationSpecFade, + placementSpec = itemPlacementSpec + ), + background = { progress -> + SwipeDismissItemBackground(progress) + }, + ) { + CartItem( + orderLine = orderLine, + removeSnack = removeSnack, + increaseItemCount = increaseItemCount, + decreaseItemCount = decreaseItemCount, + onSnackClick = onSnackClick + ) + } } - item { + item("summary") { SummaryItem( - subtotal = orderLines.map { it.snack.price * it.count }.sum(), + modifier = Modifier.animateItem( + fadeInSpec = itemAnimationSpecFade, + fadeOutSpec = itemAnimationSpecFade, + placementSpec = itemPlacementSpec + ), + subtotal = orderLines.sumOf { it.snack.price * it.count }, shippingCosts = 369 ) } - item { + item(key = "inspiredByCart") { SnackCollection( + modifier = Modifier.animateItem( + fadeInSpec = itemAnimationSpecFade, + fadeOutSpec = itemAnimationSpecFade, + placementSpec = itemPlacementSpec + ), snackCollection = inspiredByCart, onSnackClick = onSnackClick, highlight = false @@ -174,28 +220,101 @@ private fun CartContent( } } +@Composable +private fun SwipeDismissItemBackground(progress: Float) { + Column( + modifier = Modifier + .background(JetsnackTheme.colors.uiBackground) + .fillMaxWidth() + .fillMaxHeight(), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.Center + ) { + // Set 4.dp padding only if progress is less than halfway + val padding: Dp by animateDpAsState( + if (progress < 0.5f) 4.dp else 0.dp, label = "padding" + ) + BoxWithConstraints( + Modifier + .fillMaxWidth(progress) + ) { + Surface( + modifier = Modifier + .padding(padding) + .fillMaxWidth() + .height(maxWidth) + .align(Alignment.Center), + shape = RoundedCornerShape(percent = ((1 - progress) * 100).roundToInt()), + color = JetsnackTheme.colors.error + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + // Icon must be visible while in this width range + if (progress in 0.125f..0.475f) { + // Icon alpha decreases as it is about to disappear + val iconAlpha: Float by animateFloatAsState( + if (progress > 0.4f) 0.5f else 1f, label = "icon alpha" + ) + + Icon( + imageVector = Icons.Filled.DeleteForever, + modifier = Modifier + .size(32.dp) + .graphicsLayer(alpha = iconAlpha), + tint = JetsnackTheme.colors.uiBackground, + contentDescription = null, + ) + } + /*Text opacity increases as the text is supposed to appear in + the screen*/ + val textAlpha by animateFloatAsState( + if (progress > 0.5f) 1f else 0.5f, label = "text alpha" + ) + if (progress > 0.5f) { + Text( + text = stringResource(id = R.string.remove_item), + style = MaterialTheme.typography.titleMedium, + color = JetsnackTheme.colors.uiBackground, + textAlign = TextAlign.Center, + modifier = Modifier + .graphicsLayer( + alpha = textAlpha + ) + ) + } + } + } + } + } +} + @Composable fun CartItem( orderLine: OrderLine, removeSnack: (Long) -> Unit, increaseItemCount: (Long) -> Unit, decreaseItemCount: (Long) -> Unit, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier ) { val snack = orderLine.snack ConstraintLayout( modifier = modifier .fillMaxWidth() - .clickable { onSnackClick(snack.id) } + .clickable { onSnackClick(snack.id, "cart") } + .background(JetsnackTheme.colors.uiBackground) .padding(horizontal = 24.dp) + ) { val (divider, image, name, tag, priceSpacer, price, remove, quantity) = createRefs() createVerticalChain(name, tag, priceSpacer, price, chainStyle = ChainStyle.Packed) SnackImage( - imageUrl = snack.imageUrl, + imageRes = snack.imageRes, + contentDescription = null, modifier = Modifier - .preferredSize(100.dp) + .size(100.dp) .constrainAs(image) { top.linkTo(parent.top, margin = 16.dp) bottom.linkTo(parent.bottom, margin = 16.dp) @@ -204,7 +323,7 @@ fun CartItem( ) Text( text = snack.name, - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, color = JetsnackTheme.colors.textSecondary, modifier = Modifier.constrainAs(name) { linkTo( @@ -227,12 +346,13 @@ fun CartItem( ) { Icon( imageVector = Icons.Filled.Close, - tint = JetsnackTheme.colors.iconSecondary + tint = JetsnackTheme.colors.iconSecondary, + contentDescription = stringResource(R.string.label_remove) ) } Text( text = snack.tagline, - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, color = JetsnackTheme.colors.textHelp, modifier = Modifier.constrainAs(tag) { linkTo( @@ -246,14 +366,14 @@ fun CartItem( ) Spacer( Modifier - .preferredHeight(8.dp) + .height(8.dp) .constrainAs(priceSpacer) { linkTo(top = tag.bottom, bottom = price.top) } ) Text( text = formatPrice(snack.price), - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, color = JetsnackTheme.colors.textPrimary, modifier = Modifier.constrainAs(price) { linkTo( @@ -292,49 +412,51 @@ fun SummaryItem( Column(modifier) { Text( text = stringResource(R.string.cart_summary_header), - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.brand, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .padding(horizontal = 24.dp) - .preferredHeightIn(min = 56.dp) + .heightIn(min = 56.dp) .wrapContentHeight() ) Row(modifier = Modifier.padding(horizontal = 24.dp)) { Text( text = stringResource(R.string.cart_subtotal_label), - style = MaterialTheme.typography.body1, - modifier = Modifier.weight(1f) + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .weight(1f) .wrapContentWidth(Alignment.Start) .alignBy(LastBaseline) ) Text( text = formatPrice(subtotal), - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, modifier = Modifier.alignBy(LastBaseline) ) } Row(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) { Text( text = stringResource(R.string.cart_shipping_label), - style = MaterialTheme.typography.body1, - modifier = Modifier.weight(1f) + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .weight(1f) .wrapContentWidth(Alignment.Start) .alignBy(LastBaseline) ) Text( text = formatPrice(shippingCosts), - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, modifier = Modifier.alignBy(LastBaseline) ) } - Spacer(modifier = Modifier.preferredHeight(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) JetsnackDivider() Row(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) { Text( text = stringResource(R.string.cart_total_label), - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, modifier = Modifier .weight(1f) .padding(end = 16.dp) @@ -343,7 +465,7 @@ fun SummaryItem( ) Text( text = formatPrice(subtotal + shippingCosts), - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, modifier = Modifier.alignBy(LastBaseline) ) } @@ -358,6 +480,7 @@ private fun CheckoutBar(modifier: Modifier = Modifier) { JetsnackTheme.colors.uiBackground.copy(alpha = AlphaNearOpaque) ) ) { + JetsnackDivider() Row { Spacer(Modifier.weight(1f)) @@ -369,16 +492,21 @@ private fun CheckoutBar(modifier: Modifier = Modifier) { .weight(1f) ) { Text( - text = stringResource(id = R.string.cart_checkout) + text = stringResource(id = R.string.cart_checkout), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Left, + maxLines = 1 ) } } } } -@Preview("Cart") +@Preview("default") +@Preview("dark theme", uiMode = UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable -fun CartPreview() { +private fun CartPreview() { JetsnackTheme { Cart( orderLines = SnackRepo.getCart(), @@ -386,22 +514,7 @@ fun CartPreview() { increaseItemCount = {}, decreaseItemCount = {}, inspiredByCart = SnackRepo.getInspiredByCart(), - onSnackClick = {} - ) - } -} - -@Preview("Cart • Dark Theme") -@Composable -fun CartDarkPreview() { - JetsnackTheme(darkTheme = true) { - Cart( - orderLines = SnackRepo.getCart(), - removeSnack = {}, - increaseItemCount = {}, - decreaseItemCount = {}, - inspiredByCart = SnackRepo.getInspiredByCart(), - onSnackClick = { } + onSnackClick = { _, _ -> } ) } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/CartViewModel.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/CartViewModel.kt index 59d7492631..41dbfa73ab 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/CartViewModel.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/CartViewModel.kt @@ -17,8 +17,11 @@ package com.example.jetsnack.ui.home.cart import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.jetsnack.R import com.example.jetsnack.model.OrderLine import com.example.jetsnack.model.SnackRepo +import com.example.jetsnack.model.SnackbarManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,31 +30,47 @@ import kotlinx.coroutines.flow.StateFlow * * TODO: Move data to Repository so it can be displayed and changed consistently throughout the app. */ -class CartViewModel : ViewModel() { +class CartViewModel( + private val snackbarManager: SnackbarManager, + snackRepository: SnackRepo +) : ViewModel() { + private val _orderLines: MutableStateFlow<List<OrderLine>> = - MutableStateFlow(SnackRepo.getCart()) + MutableStateFlow(snackRepository.getCart()) val orderLines: StateFlow<List<OrderLine>> get() = _orderLines - fun removeSnack(snackId: Long) { - _orderLines.value = _orderLines.value.filter { it.snack.id != snackId } - } + // Logic to show errors every few requests + private var requestCount = 0 + private fun shouldRandomlyFail(): Boolean = ++requestCount % 5 == 0 fun increaseSnackCount(snackId: Long) { - val currentCount = _orderLines.value.first { it.snack.id == snackId }.count - updateSnackCount(snackId, currentCount + 1) + if (!shouldRandomlyFail()) { + val currentCount = _orderLines.value.first { it.snack.id == snackId }.count + updateSnackCount(snackId, currentCount + 1) + } else { + snackbarManager.showMessage(R.string.cart_increase_error) + } } fun decreaseSnackCount(snackId: Long) { - val currentCount = _orderLines.value.first { it.snack.id == snackId }.count - if (currentCount == 1) { - // remove snack from cart - removeSnack(snackId) + if (!shouldRandomlyFail()) { + val currentCount = _orderLines.value.first { it.snack.id == snackId }.count + if (currentCount == 1) { + // remove snack from cart + removeSnack(snackId) + } else { + // update quantity in cart + updateSnackCount(snackId, currentCount - 1) + } } else { - // update quantity in cart - updateSnackCount(snackId, currentCount - 1) + snackbarManager.showMessage(R.string.cart_decrease_error) } } + fun removeSnack(snackId: Long) { + _orderLines.value = _orderLines.value.filter { it.snack.id != snackId } + } + private fun updateSnackCount(snackId: Long, count: Int) { _orderLines.value = _orderLines.value.map { if (it.snack.id == snackId) { @@ -61,4 +80,19 @@ class CartViewModel : ViewModel() { } } } + + /** + * Factory for CartViewModel that takes SnackbarManager as a dependency + */ + companion object { + fun provideFactory( + snackbarManager: SnackbarManager = SnackbarManager, + snackRepository: SnackRepo = SnackRepo + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun <T : ViewModel> create(modelClass: Class<T>): T { + return CartViewModel(snackbarManager, snackRepository) as T + } + } + } } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/SwipeDismissItem.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/SwipeDismissItem.kt new file mode 100644 index 0000000000..ccc695715c --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/cart/SwipeDismissItem.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.ui.home.cart + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +/** + * Holds the Swipe to dismiss composable, its animation and the current state + */ +fun SwipeDismissItem( + modifier: Modifier = Modifier, + enter: EnterTransition = expandVertically(), + exit: ExitTransition = shrinkVertically(), + background: @Composable (progress: Float) -> Unit, + content: @Composable (isDismissed: Boolean) -> Unit, +) { + // Hold the current state from the Swipe to Dismiss composable + val dismissState = rememberSwipeToDismissBoxState() + // Boolean value used for hiding the item if the current state is dismissed + val isDismissed = dismissState.currentValue == SwipeToDismissBoxValue.EndToStart + + AnimatedVisibility( + modifier = modifier, + visible = !isDismissed, + enter = enter, + exit = exit + ) { + SwipeToDismissBox( + modifier = modifier, + state = dismissState, + enableDismissFromStartToEnd = false, + backgroundContent = { background(dismissState.progress) }, + content = { content(isDismissed) } + ) + } +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt index 79b34a27d2..2bce3b328c 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Categories.kt @@ -16,20 +16,22 @@ package com.example.jetsnack.ui.home.search +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredHeightIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -40,6 +42,7 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp +import com.example.jetsnack.R import com.example.jetsnack.model.SearchCategory import com.example.jetsnack.model.SearchCategoryCollection import com.example.jetsnack.ui.components.SnackImage @@ -56,7 +59,7 @@ fun SearchCategories( SearchCategoryCollection(collection, index) } } - Spacer(Modifier.preferredHeight(8.dp)) + Spacer(Modifier.height(8.dp)) } @Composable @@ -68,17 +71,17 @@ private fun SearchCategoryCollection( Column(modifier) { Text( text = collection.name, - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.textPrimary, modifier = Modifier - .preferredHeightIn(min = 56.dp) + .heightIn(min = 56.dp) .padding(horizontal = 24.dp, vertical = 4.dp) .wrapContentHeight() ) VerticalGrid(Modifier.padding(horizontal = 16.dp)) { val gradient = when (index % 2) { 0 -> JetsnackTheme.colors.gradient2_2 - else -> JetsnackTheme.colors.gradient3_2 + else -> JetsnackTheme.colors.gradient2_3 } collection.categories.forEach { category -> SearchCategory( @@ -88,7 +91,7 @@ private fun SearchCategoryCollection( ) } } - Spacer(Modifier.preferredHeight(4.dp)) + Spacer(Modifier.height(4.dp)) } } @@ -112,14 +115,15 @@ private fun SearchCategory( content = { Text( text = category.name, - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, color = JetsnackTheme.colors.textSecondary, modifier = Modifier .padding(4.dp) .padding(start = 8.dp) ) SnackImage( - imageUrl = category.imageUrl, + imageRes = category.imageRes, + contentDescription = null, modifier = Modifier.fillMaxSize() ) } @@ -130,17 +134,17 @@ private fun SearchCategory( // Image is sized to the larger of height of item, or a minimum value // i.e. may appear larger than item (but clipped to the item bounds) - val imageSize = max(MinImageSize.toIntPx(), constraints.maxHeight) + val imageSize = max(MinImageSize.roundToPx(), constraints.maxHeight) val imagePlaceable = measurables[1].measure(Constraints.fixed(imageSize, imageSize)) layout( width = constraints.maxWidth, height = constraints.minHeight ) { - textPlaceable.place( + textPlaceable.placeRelative( x = 0, y = (constraints.maxHeight - textPlaceable.height) / 2 // centered ) - imagePlaceable.place( + imagePlaceable.placeRelative( // image is placed to end of text i.e. will overflow to the end (but be clipped) x = textWidth, y = (constraints.maxHeight - imagePlaceable.height) / 2 // centered @@ -149,28 +153,16 @@ private fun SearchCategory( } } -@Preview("Category") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable private fun SearchCategoryPreview() { JetsnackTheme { SearchCategory( category = SearchCategory( name = "Desserts", - imageUrl = "" - ), - gradient = JetsnackTheme.colors.gradient3_2 - ) - } -} - -@Preview("Category • Dark") -@Composable -private fun SearchCategoryDarkPreview() { - JetsnackTheme(darkTheme = true) { - SearchCategory( - category = SearchCategory( - name = "Desserts", - imageUrl = "" + imageRes = R.drawable.desserts ), gradient = JetsnackTheme.colors.gradient3_2 ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Results.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Results.kt index 4c717322d0..dac9edc0f3 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Results.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Results.kt @@ -16,39 +16,39 @@ package com.example.jetsnack.ui.home.search +import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.ChainStyle import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ConstraintLayout import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredSize +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ChainStyle +import androidx.constraintlayout.compose.ConstraintLayout import com.example.jetsnack.R -import com.example.jetsnack.model.Filter import com.example.jetsnack.model.Snack import com.example.jetsnack.model.snacks -import com.example.jetsnack.ui.components.FilterBar import com.example.jetsnack.ui.components.JetsnackButton import com.example.jetsnack.ui.components.JetsnackDivider import com.example.jetsnack.ui.components.JetsnackSurface @@ -59,14 +59,12 @@ import com.example.jetsnack.ui.utils.formatPrice @Composable fun SearchResults( searchResults: List<Snack>, - filters: List<Filter>, - onSnackClick: (Long) -> Unit + onSnackClick: (Long, String) -> Unit ) { Column { - FilterBar(filters) Text( text = stringResource(R.string.search_count, searchResults.size), - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.textPrimary, modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp) ) @@ -81,14 +79,14 @@ fun SearchResults( @Composable private fun SearchResult( snack: Snack, - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, showDivider: Boolean, modifier: Modifier = Modifier ) { ConstraintLayout( modifier = modifier .fillMaxWidth() - .clickable { onSnackClick(snack.id) } + .clickable { onSnackClick(snack.id, "search") } .padding(horizontal = 24.dp) ) { val (divider, image, name, tag, priceSpacer, price, add) = createRefs() @@ -102,9 +100,10 @@ private fun SearchResult( ) } SnackImage( - imageUrl = snack.imageUrl, + imageRes = snack.imageRes, + contentDescription = null, modifier = Modifier - .preferredSize(100.dp) + .size(100.dp) .constrainAs(image) { linkTo( top = parent.top, @@ -117,7 +116,7 @@ private fun SearchResult( ) Text( text = snack.name, - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, color = JetsnackTheme.colors.textSecondary, modifier = Modifier.constrainAs(name) { linkTo( @@ -131,7 +130,7 @@ private fun SearchResult( ) Text( text = snack.tagline, - style = MaterialTheme.typography.body1, + style = MaterialTheme.typography.bodyLarge, color = JetsnackTheme.colors.textHelp, modifier = Modifier.constrainAs(tag) { linkTo( @@ -145,14 +144,14 @@ private fun SearchResult( ) Spacer( Modifier - .preferredHeight(8.dp) + .height(8.dp) .constrainAs(priceSpacer) { linkTo(top = tag.bottom, bottom = price.top) } ) Text( text = formatPrice(snack.price), - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, color = JetsnackTheme.colors.textPrimary, modifier = Modifier.constrainAs(price) { linkTo( @@ -169,13 +168,16 @@ private fun SearchResult( shape = CircleShape, contentPadding = PaddingValues(0.dp), modifier = Modifier - .preferredSize(36.dp) + .size(36.dp) .constrainAs(add) { linkTo(top = parent.top, bottom = parent.bottom) end.linkTo(parent.end) } ) { - Icon(Icons.Outlined.Add) + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.label_add) + ) } } } @@ -192,46 +194,37 @@ fun NoResults( .wrapContentSize() .padding(24.dp) ) { - Image(vectorResource(R.drawable.empty_state_search)) - Spacer(Modifier.preferredHeight(24.dp)) + Image( + painterResource(R.drawable.empty_state_search), + contentDescription = null + ) + Spacer(Modifier.height(24.dp)) Text( text = stringResource(R.string.search_no_matches, query), - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) - Spacer(Modifier.preferredHeight(16.dp)) + Spacer(Modifier.height(16.dp)) Text( text = stringResource(R.string.search_no_matches_retry), - style = MaterialTheme.typography.body2, + style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) } } -@Preview("Search Result") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable private fun SearchResultPreview() { JetsnackTheme { JetsnackSurface { SearchResult( snack = snacks[0], - onSnackClick = { }, - showDivider = false - ) - } - } -} - -@Preview("Search Result • Dark") -@Composable -private fun SearchResultDarkPreview() { - JetsnackTheme(darkTheme = true) { - JetsnackSurface { - SearchResult( - snack = snacks[0], - onSnackClick = { }, + onSnackClick = { _, _ -> }, showDivider = false ) } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Search.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Search.kt index 2d5b6a1836..059e023752 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Search.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Search.kt @@ -16,27 +16,29 @@ package com.example.jetsnack.ui.home.search +import android.content.res.Configuration import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -46,7 +48,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.isFocused import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue @@ -62,11 +63,10 @@ import com.example.jetsnack.model.SnackRepo import com.example.jetsnack.ui.components.JetsnackDivider import com.example.jetsnack.ui.components.JetsnackSurface import com.example.jetsnack.ui.theme.JetsnackTheme -import dev.chrisbanes.accompanist.insets.statusBarsPadding @Composable fun Search( - onSnackClick: (Long) -> Unit, + onSnackClick: (Long, String) -> Unit, modifier: Modifier = Modifier, state: SearchState = rememberSearchState() ) { @@ -92,13 +92,16 @@ fun Search( SearchDisplay.Categories -> SearchCategories(state.categories) SearchDisplay.Suggestions -> SearchSuggestions( suggestions = state.suggestions, - onSuggestionSelect = { suggestion -> state.query = TextFieldValue(suggestion) } + onSuggestionSelect = { suggestion -> + state.query = TextFieldValue(suggestion) + } ) + SearchDisplay.Results -> SearchResults( state.searchResults, - state.filters, onSnackClick ) + SearchDisplay.NoResults -> NoResults(state.query.text) } } @@ -174,7 +177,7 @@ private fun SearchBar( shape = MaterialTheme.shapes.small, modifier = modifier .fillMaxWidth() - .preferredHeight(56.dp) + .height(56.dp) .padding(horizontal = 24.dp, vertical = 8.dp) ) { Box(Modifier.fillMaxSize()) { @@ -190,8 +193,9 @@ private fun SearchBar( if (searchFocused) { IconButton(onClick = onClearQuery) { Icon( - imageVector = Icons.Outlined.ArrowBack, - tint = JetsnackTheme.colors.iconPrimary + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + tint = JetsnackTheme.colors.iconPrimary, + contentDescription = stringResource(R.string.label_back) ) } } @@ -209,10 +213,10 @@ private fun SearchBar( color = JetsnackTheme.colors.iconPrimary, modifier = Modifier .padding(horizontal = 6.dp) - .preferredSize(36.dp) + .size(36.dp) ) } else { - Spacer(Modifier.preferredWidth(IconSize)) // balance arrow icon + Spacer(Modifier.width(IconSize)) // balance arrow icon } } } @@ -225,13 +229,16 @@ private val IconSize = 48.dp private fun SearchHint() { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxSize().wrapContentSize() + modifier = Modifier + .fillMaxSize() + .wrapContentSize() ) { Icon( imageVector = Icons.Outlined.Search, - tint = JetsnackTheme.colors.textHelp + tint = JetsnackTheme.colors.textHelp, + contentDescription = stringResource(R.string.label_search) ) - Spacer(Modifier.preferredWidth(8.dp)) + Spacer(Modifier.width(8.dp)) Text( text = stringResource(R.string.search_jetsnack), color = JetsnackTheme.colors.textHelp @@ -239,7 +246,9 @@ private fun SearchHint() { } } -@Preview("Search Bar") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable private fun SearchBarPreview() { JetsnackTheme { @@ -255,20 +264,3 @@ private fun SearchBarPreview() { } } } - -@Preview("Search Bar • Dark") -@Composable -private fun SearchBarDarkPreview() { - JetsnackTheme(darkTheme = true) { - JetsnackSurface { - SearchBar( - query = TextFieldValue(""), - onQueryChange = { }, - searchFocused = false, - onSearchFocusChange = { }, - onClearQuery = { }, - searching = false - ) - } - } -} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Suggestions.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Suggestions.kt index 08eb4f2dc5..638b70d686 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Suggestions.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/home/search/Suggestions.kt @@ -16,16 +16,18 @@ package com.example.jetsnack.ui.home.search +import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredHeightIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.foundation.lazy.items +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 @@ -54,7 +56,7 @@ fun SearchSuggestions( ) } item { - Spacer(Modifier.preferredHeight(4.dp)) + Spacer(Modifier.height(4.dp)) } } } @@ -67,10 +69,10 @@ private fun SuggestionHeader( ) { Text( text = name, - style = MaterialTheme.typography.h6, + style = MaterialTheme.typography.titleLarge, color = JetsnackTheme.colors.textPrimary, modifier = modifier - .preferredHeightIn(min = 56.dp) + .heightIn(min = 56.dp) .padding(horizontal = 24.dp, vertical = 4.dp) .wrapContentHeight() ) @@ -84,16 +86,18 @@ private fun Suggestion( ) { Text( text = suggestion, - style = MaterialTheme.typography.subtitle1, + style = MaterialTheme.typography.titleMedium, modifier = modifier - .preferredHeightIn(min = 48.dp) + .heightIn(min = 48.dp) .clickable { onSuggestionSelect(suggestion) } .padding(start = 24.dp) .wrapContentSize(Alignment.CenterStart) ) } -@Preview +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable fun PreviewSuggestions() { JetsnackTheme { diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/JetsnackNavController.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/JetsnackNavController.kt new file mode 100644 index 0000000000..dbd6c92f91 --- /dev/null +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/navigation/JetsnackNavController.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetsnack.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination +import androidx.navigation.NavGraph +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController + +/** + * Destinations used in the [JetsnackApp]. + */ +object MainDestinations { + const val HOME_ROUTE = "home" + const val SNACK_DETAIL_ROUTE = "snack" + const val SNACK_ID_KEY = "snackId" + const val ORIGIN = "origin" +} + +/** + * Remembers and creates an instance of [JetsnackNavController] + */ +@Composable +fun rememberJetsnackNavController( + navController: NavHostController = rememberNavController() +): JetsnackNavController = remember(navController) { + JetsnackNavController(navController) +} + +/** + * Responsible for holding UI Navigation logic. + */ +@Stable +class JetsnackNavController( + val navController: NavHostController, +) { + + // ---------------------------------------------------------- + // Navigation state source of truth + // ---------------------------------------------------------- + + fun upPress() { + navController.navigateUp() + } + + fun navigateToBottomBarRoute(route: String) { + if (route != navController.currentDestination?.route) { + navController.navigate(route) { + launchSingleTop = true + restoreState = true + // Pop up backstack to the first destination and save state. This makes going back + // to the start destination when pressing back in any other bottom tab. + popUpTo(findStartDestination(navController.graph).id) { + saveState = true + } + } + } + } + + fun navigateToSnackDetail(snackId: Long, origin: String, from: NavBackStackEntry) { + // In order to discard duplicated navigation events, we check the Lifecycle + if (from.lifecycleIsResumed()) { + navController.navigate("${MainDestinations.SNACK_DETAIL_ROUTE}/$snackId?origin=$origin") + } + } +} + +/** + * If the lifecycle is not resumed it means this NavBackStackEntry already processed a nav event. + * + * This is used to de-duplicate navigation events. + */ +private fun NavBackStackEntry.lifecycleIsResumed() = + this.lifecycle.currentState == Lifecycle.State.RESUMED + +private val NavGraph.startDestination: NavDestination? + get() = findNode(startDestinationId) + +/** + * Copied from similar function in NavigationUI.kt + * + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigation/navigation-ui/src/main/java/androidx/navigation/ui/NavigationUI.kt + */ +private tailrec fun findStartDestination(graph: NavDestination): NavDestination { + return if (graph is NavGraph) findStartDestination(graph.startDestination!!) else graph +} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt index 558229db3a..043e7ecc74 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/snackdetail/SnackDetail.kt @@ -14,11 +14,34 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class, ExperimentalAnimationApi::class) + package com.example.jetsnack.ui.snackdetail +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.ScrollableColumn import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,32 +49,49 @@ 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.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredHeightIn -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.layout.Layout -import androidx.compose.ui.platform.AmbientDensity +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp @@ -60,8 +100,13 @@ import com.example.jetsnack.R import com.example.jetsnack.model.Snack import com.example.jetsnack.model.SnackCollection import com.example.jetsnack.model.SnackRepo +import com.example.jetsnack.ui.LocalNavAnimatedVisibilityScope +import com.example.jetsnack.ui.LocalSharedTransitionScope +import com.example.jetsnack.ui.SnackSharedElementKey +import com.example.jetsnack.ui.SnackSharedElementType import com.example.jetsnack.ui.components.JetsnackButton import com.example.jetsnack.ui.components.JetsnackDivider +import com.example.jetsnack.ui.components.JetsnackPreviewWrapper import com.example.jetsnack.ui.components.JetsnackSurface import com.example.jetsnack.ui.components.QuantitySelector import com.example.jetsnack.ui.components.SnackCollection @@ -69,8 +114,6 @@ import com.example.jetsnack.ui.components.SnackImage import com.example.jetsnack.ui.theme.JetsnackTheme import com.example.jetsnack.ui.theme.Neutral8 import com.example.jetsnack.ui.utils.formatPrice -import dev.chrisbanes.accompanist.insets.navigationBarsPadding -import dev.chrisbanes.accompanist.insets.statusBarsPadding import kotlin.math.max import kotlin.math.min @@ -85,52 +128,158 @@ private val ExpandedImageSize = 300.dp private val CollapsedImageSize = 150.dp private val HzPadding = Modifier.padding(horizontal = 24.dp) +fun <T> spatialExpressiveSpring() = spring<T>( + dampingRatio = 0.8f, + stiffness = 380f +) + +fun <T> nonSpatialExpressiveSpring() = spring<T>( + dampingRatio = 1f, + stiffness = 1600f +) + +val snackDetailBoundsTransform = BoundsTransform { _, _ -> + spatialExpressiveSpring() +} + @Composable fun SnackDetail( snackId: Long, + origin: String, upPress: () -> Unit ) { val snack = remember(snackId) { SnackRepo.getSnack(snackId) } val related = remember(snackId) { SnackRepo.getRelated(snackId) } - - Box(Modifier.fillMaxSize()) { - val scroll = rememberScrollState(0f) - Header() - Body(related, scroll) - Title(snack, scroll.value) - Image(snack.imageUrl, scroll.value) - Up(upPress) - CartBottomBar(modifier = Modifier.align(Alignment.BottomCenter)) + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No Scope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No Scope found") + val roundedCornerAnim by animatedVisibilityScope.transition + .animateDp(label = "rounded corner") { enterExit: EnterExitState -> + when (enterExit) { + EnterExitState.PreEnter -> 20.dp + EnterExitState.Visible -> 0.dp + EnterExitState.PostExit -> 20.dp + } + } + with(sharedTransitionScope) { + Box( + Modifier + .clip(RoundedCornerShape(roundedCornerAnim)) + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = origin, + type = SnackSharedElementType.Bounds + ) + ), + animatedVisibilityScope, + clipInOverlayDuringTransition = + OverlayClip(RoundedCornerShape(roundedCornerAnim)), + boundsTransform = snackDetailBoundsTransform, + exit = fadeOut(nonSpatialExpressiveSpring()), + enter = fadeIn(nonSpatialExpressiveSpring()), + ) + .fillMaxSize() + .background(color = JetsnackTheme.colors.uiBackground) + ) { + val scroll = rememberScrollState(0) + Header(snack.id, origin = origin) + Body(related, scroll) + Title(snack, origin) { scroll.value } + Image(snackId, origin, snack.imageRes) { scroll.value } + Up(upPress) + CartBottomBar(modifier = Modifier.align(Alignment.BottomCenter)) + } } } @Composable -private fun Header() { - Spacer( - modifier = Modifier - .preferredHeight(280.dp) - .fillMaxWidth() - .background(Brush.horizontalGradient(JetsnackTheme.colors.interactivePrimary)) - ) +private fun Header(snackId: Long, origin: String) { + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalArgumentException("No Scope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalArgumentException("No Scope found") + + with(sharedTransitionScope) { + val brushColors = JetsnackTheme.colors.tornado1 + + val infiniteTransition = rememberInfiniteTransition(label = "background") + val targetOffset = with(LocalDensity.current) { + 1000.dp.toPx() + } + val offset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = targetOffset, + animationSpec = infiniteRepeatable( + tween(50000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "offset" + ) + Spacer( + modifier = Modifier + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snackId, + origin = origin, + type = SnackSharedElementType.Background + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform, + enter = fadeIn(nonSpatialExpressiveSpring()), + exit = fadeOut(nonSpatialExpressiveSpring()), + resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds() + ) + .height(280.dp) + .fillMaxWidth() + .blur(40.dp) + .drawWithCache { + val brushSize = 400f + val brush = Brush.linearGradient( + colors = brushColors, + start = Offset(offset, offset), + end = Offset(offset + brushSize, offset + brushSize), + tileMode = TileMode.Mirror + ) + onDrawBehind { + drawRect(brush) + } + } + ) + } } @Composable -private fun Up(upPress: () -> Unit) { - IconButton( - onClick = upPress, - modifier = Modifier - .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 10.dp) - .preferredSize(36.dp) - .background( - color = Neutral8.copy(alpha = 0.32f), - shape = CircleShape +private fun SharedTransitionScope.Up(upPress: () -> Unit) { + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalArgumentException("No Scope found") + with(animatedVisibilityScope) { + IconButton( + onClick = upPress, + modifier = Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 3f) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 10.dp) + .size(36.dp) + .animateEnterExit( + enter = scaleIn(tween(300, delayMillis = 300)), + exit = scaleOut(tween(20)) + ) + .background( + color = Neutral8.copy(alpha = 0.32f), + shape = CircleShape + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + tint = JetsnackTheme.colors.iconInteractive, + contentDescription = stringResource(R.string.label_back), ) - ) { - Icon( - imageVector = Icons.Outlined.ArrowBack, - tint = JetsnackTheme.colors.iconInteractive - ) + } } } @@ -139,69 +288,104 @@ private fun Body( related: List<SnackCollection>, scroll: ScrollState ) { - Column { - Spacer( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .preferredHeight(MinTitleOffset) - ) - ScrollableColumn(scrollState = scroll) { - Spacer(Modifier.preferredHeight(GradientScroll)) - JetsnackSurface(Modifier.fillMaxWidth()) { - Column { - Spacer(Modifier.preferredHeight(ImageOverlap)) - Spacer(Modifier.preferredHeight(TitleHeight)) - - Spacer(Modifier.preferredHeight(16.dp)) - Text( - text = stringResource(R.string.detail_header), - style = MaterialTheme.typography.overline, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) - Spacer(Modifier.preferredHeight(4.dp)) - Text( - text = stringResource(R.string.detail_placeholder), - style = MaterialTheme.typography.body1, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) - - Spacer(Modifier.preferredHeight(40.dp)) - Text( - text = stringResource(R.string.ingredients), - style = MaterialTheme.typography.overline, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) - Spacer(Modifier.preferredHeight(4.dp)) - Text( - text = stringResource(R.string.ingredients_list), - style = MaterialTheme.typography.body1, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) + val sharedTransitionScope = + LocalSharedTransitionScope.current ?: throw IllegalStateException("No scope found") + with(sharedTransitionScope) { + Column(modifier = Modifier.skipToLookaheadSize()) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .height(MinTitleOffset) + ) - Spacer(Modifier.preferredHeight(16.dp)) - JetsnackDivider() + Column( + modifier = Modifier.verticalScroll(scroll) + ) { + Spacer(Modifier.height(GradientScroll)) + Spacer(Modifier.height(ImageOverlap)) + JetsnackSurface( + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + Column { + Spacer(Modifier.height(TitleHeight)) + Text( + text = stringResource(R.string.detail_header), + style = MaterialTheme.typography.labelSmall, + color = JetsnackTheme.colors.textHelp, + modifier = HzPadding + ) + Spacer(Modifier.height(16.dp)) + var seeMore by remember { mutableStateOf(true) } + with(sharedTransitionScope) { + Text( + text = stringResource(R.string.detail_placeholder), + style = MaterialTheme.typography.bodyLarge, + color = JetsnackTheme.colors.textHelp, + maxLines = if (seeMore) 5 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis, + modifier = HzPadding.skipToLookaheadSize() - related.forEach { snackCollection -> - key(snackCollection.id) { - SnackCollection( - snackCollection = snackCollection, - onSnackClick = { }, - highlight = false ) } - } + val textButton = if (seeMore) { + stringResource(id = R.string.see_more) + } else { + stringResource(id = R.string.see_less) + } - Spacer( - modifier = Modifier - .padding(bottom = BottomBarHeight) - .navigationBarsPadding(left = false, right = false) - .preferredHeight(8.dp) - ) + Text( + text = textButton, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + color = JetsnackTheme.colors.textLink, + modifier = Modifier + .heightIn(20.dp) + .fillMaxWidth() + .padding(top = 15.dp) + .clickable { + seeMore = !seeMore + } + .skipToLookaheadSize() + ) + + Spacer(Modifier.height(40.dp)) + Text( + text = stringResource(R.string.ingredients), + style = MaterialTheme.typography.labelSmall, + color = JetsnackTheme.colors.textHelp, + modifier = HzPadding + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.ingredients_list), + style = MaterialTheme.typography.bodyLarge, + color = JetsnackTheme.colors.textHelp, + modifier = HzPadding + ) + + Spacer(Modifier.height(16.dp)) + JetsnackDivider() + + related.forEach { snackCollection -> + key(snackCollection.id) { + SnackCollection( + snackCollection = snackCollection, + onSnackClick = { _, _ -> }, + highlight = false + ) + } + } + + Spacer( + modifier = Modifier + .padding(bottom = BottomBarHeight) + .navigationBarsPadding() + .height(8.dp) + ) + } } } } @@ -209,67 +393,138 @@ private fun Body( } @Composable -private fun Title(snack: Snack, scroll: Float) { - val maxOffset = with(AmbientDensity.current) { MaxTitleOffset.toPx() } - val minOffset = with(AmbientDensity.current) { MinTitleOffset.toPx() } - val offset = (maxOffset - scroll).coerceAtLeast(minOffset) - Column( - verticalArrangement = Arrangement.Bottom, - modifier = Modifier - .preferredHeightIn(min = TitleHeight) - .statusBarsPadding() - .graphicsLayer { translationY = offset } - .background(color = JetsnackTheme.colors.uiBackground) - ) { - Spacer(Modifier.preferredHeight(16.dp)) - Text( - text = snack.name, - style = MaterialTheme.typography.h4, - color = JetsnackTheme.colors.textSecondary, - modifier = HzPadding - ) - Text( - text = snack.tagline, - style = MaterialTheme.typography.subtitle2, - fontSize = 20.sp, - color = JetsnackTheme.colors.textHelp, - modifier = HzPadding - ) - Spacer(Modifier.preferredHeight(4.dp)) - Text( - text = formatPrice(snack.price), - style = MaterialTheme.typography.h6, - color = JetsnackTheme.colors.textPrimary, - modifier = HzPadding - ) +private fun Title(snack: Snack, origin: String, scrollProvider: () -> Int) { + val maxOffset = with(LocalDensity.current) { MaxTitleOffset.toPx() } + val minOffset = with(LocalDensity.current) { MinTitleOffset.toPx() } + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalArgumentException("No Scope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalArgumentException("No Scope found") - Spacer(Modifier.preferredHeight(8.dp)) - JetsnackDivider() + with(sharedTransitionScope) { + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = TitleHeight) + .statusBarsPadding() + .offset { + val scroll = scrollProvider() + val offset = (maxOffset - scroll).coerceAtLeast(minOffset) + IntOffset(x = 0, y = offset.toInt()) + } + .background(JetsnackTheme.colors.uiBackground) + ) { + Spacer(Modifier.height(16.dp)) + Text( + text = snack.name, + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.headlineMedium, + color = JetsnackTheme.colors.textSecondary, + modifier = HzPadding + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = origin, + type = SnackSharedElementType.Title + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform + ) + .wrapContentWidth() + ) + Text( + text = snack.tagline, + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.titleSmall, + fontSize = 20.sp, + color = JetsnackTheme.colors.textHelp, + modifier = HzPadding + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snack.id, + origin = origin, + type = SnackSharedElementType.Tagline + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = snackDetailBoundsTransform + ) + .wrapContentWidth() + ) + Spacer(Modifier.height(4.dp)) + with(animatedVisibilityScope) { + Text( + text = formatPrice(snack.price), + style = MaterialTheme.typography.titleLarge, + color = JetsnackTheme.colors.textPrimary, + modifier = HzPadding + .animateEnterExit( + enter = fadeIn() + slideInVertically { -it / 3 }, + exit = fadeOut() + slideOutVertically { -it / 3 } + ) + .skipToLookaheadSize() + ) + } + Spacer(Modifier.height(8.dp)) + JetsnackDivider(modifier = Modifier) + } } } @Composable private fun Image( - imageUrl: String, - scroll: Float + snackId: Long, + origin: String, + @DrawableRes + imageRes: Int, + scrollProvider: () -> Int ) { - val collapseRange = with(AmbientDensity.current) { (MaxTitleOffset - MinTitleOffset).toPx() } - val collapseFraction = (scroll / collapseRange).coerceIn(0f, 1f) + val collapseRange = with(LocalDensity.current) { (MaxTitleOffset - MinTitleOffset).toPx() } + val collapseFractionProvider = { + (scrollProvider() / collapseRange).coerceIn(0f, 1f) + } CollapsingImageLayout( - collapseFraction = collapseFraction, - modifier = HzPadding.then(Modifier.statusBarsPadding()) + collapseFractionProvider = collapseFractionProvider, + modifier = HzPadding.statusBarsPadding() ) { - SnackImage( - imageUrl = imageUrl, - modifier = Modifier.fillMaxSize() - ) + val sharedTransitionScope = LocalSharedTransitionScope.current + ?: throw IllegalStateException("No sharedTransitionScope found") + val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current + ?: throw IllegalStateException("No animatedVisibilityScope found") + + with(sharedTransitionScope) { + SnackImage( + imageRes = imageRes, + contentDescription = null, + modifier = Modifier + .sharedBounds( + rememberSharedContentState( + key = SnackSharedElementKey( + snackId = snackId, + origin = origin, + type = SnackSharedElementType.Image + ) + ), + animatedVisibilityScope = animatedVisibilityScope, + exit = fadeOut(), + enter = fadeIn(), + boundsTransform = snackDetailBoundsTransform + ) + .fillMaxSize() + + ) + } } } @Composable private fun CollapsingImageLayout( - collapseFraction: Float, + collapseFractionProvider: () -> Float, modifier: Modifier = Modifier, content: @Composable () -> Unit ) { @@ -279,12 +534,14 @@ private fun CollapsingImageLayout( ) { measurables, constraints -> check(measurables.size == 1) - val imageMaxSize = min(ExpandedImageSize.toIntPx(), constraints.maxWidth) - val imageMinSize = max(CollapsedImageSize.toIntPx(), constraints.minWidth) + val collapseFraction = collapseFractionProvider() + + val imageMaxSize = min(ExpandedImageSize.roundToPx(), constraints.maxWidth) + val imageMinSize = max(CollapsedImageSize.roundToPx(), constraints.minWidth) val imageWidth = lerp(imageMaxSize, imageMinSize, collapseFraction) val imagePlaceable = measurables[0].measure(Constraints.fixed(imageWidth, imageWidth)) - val imageY = lerp(MinTitleOffset, MinImageOffset, collapseFraction).toIntPx() + val imageY = lerp(MinTitleOffset, MinImageOffset, collapseFraction).roundToPx() val imageX = lerp( (constraints.maxWidth - imageWidth) / 2, // centered when expanded constraints.maxWidth - imageWidth, // right aligned when collapsed @@ -294,61 +551,76 @@ private fun CollapsingImageLayout( width = constraints.maxWidth, height = imageY + imageWidth ) { - imagePlaceable.place(imageX, imageY) + imagePlaceable.placeRelative(imageX, imageY) } } } @Composable private fun CartBottomBar(modifier: Modifier = Modifier) { - val (count, updateCount) = remember { mutableStateOf(1) } - JetsnackSurface(modifier) { - Column { - JetsnackDivider() - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .navigationBarsPadding(left = false, right = false) - .then(HzPadding) - .preferredHeightIn(min = BottomBarHeight) - ) { - QuantitySelector( - count = count, - decreaseItemCount = { if (count > 0) updateCount(count - 1) }, - increaseItemCount = { updateCount(count + 1) } - ) - Spacer(Modifier.preferredWidth(16.dp)) - JetsnackButton( - onClick = { /* todo */ }, - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(R.string.add_to_cart), - maxLines = 1 + val (count, updateCount) = remember { mutableIntStateOf(1) } + val sharedTransitionScope = + LocalSharedTransitionScope.current ?: throw IllegalStateException("No Shared scope") + val animatedVisibilityScope = + LocalNavAnimatedVisibilityScope.current ?: throw IllegalStateException("No Shared scope") + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + JetsnackSurface( + modifier = modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 4f) + .animateEnterExit( + enter = slideInVertically( + tween( + 300, + delayMillis = 300 + ) + ) { it } + fadeIn(tween(300, delayMillis = 300)), + exit = slideOutVertically(tween(50)) { it } + + fadeOut(tween(50)) ) + ) { + Column { + JetsnackDivider() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .navigationBarsPadding() + .then(HzPadding) + .heightIn(min = BottomBarHeight) + ) { + QuantitySelector( + count = count, + decreaseItemCount = { if (count > 0) updateCount(count - 1) }, + increaseItemCount = { updateCount(count + 1) } + ) + Spacer(Modifier.width(16.dp)) + JetsnackButton( + onClick = { /* todo */ }, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.add_to_cart), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } } } } } } -@Preview("Snack Detail") +@Preview("default") +@Preview("dark theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview("large font", fontScale = 2f) @Composable private fun SnackDetailPreview() { - JetsnackTheme { - SnackDetail( - snackId = 1L, - upPress = { } - ) - } -} - -@Preview("Snack Detail • Dark") -@Composable -private fun SnackDetailDarkPreview() { - JetsnackTheme(darkTheme = true) { + JetsnackPreviewWrapper { SnackDetail( snackId = 1L, + origin = "details", upPress = { } ) } diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Color.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Color.kt index 2c5b04cc29..eb99c98163 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Color.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Color.kt @@ -71,7 +71,7 @@ val Rose1 = Color(0xfffed6e2) val Rose0 = Color(0xfffff2f6) val Neutral8 = Color(0xff121212) -val Neutral7 = Color(0xdef000000) +val Neutral7 = Color(0xde000000) val Neutral6 = Color(0x99000000) val Neutral5 = Color(0x61000000) val Neutral4 = Color(0x1f000000) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Shape.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Shape.kt index 76d6b842d4..e9887fafe9 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Shape.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Shape.kt @@ -17,7 +17,7 @@ package com.example.jetsnack.ui.theme import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes +import androidx.compose.material3.Shapes import androidx.compose.ui.unit.dp val Shapes = Shapes( diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt index d7d2d1f97c..fe6ff88cce 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Theme.kt @@ -17,22 +17,17 @@ package com.example.jetsnack.ui.theme import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.onCommit -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticAmbientOf +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -import com.example.jetsnack.ui.utils.SysUiController private val LightColorPalette = JetsnackColors( brand = Shadow5, + brandSecondary = Ocean3, uiBackground = Neutral0, uiBorder = Neutral4, uiFloated = FunctionalGrey, @@ -50,11 +45,14 @@ private val LightColorPalette = JetsnackColors( gradient3_2 = listOf(Rose2, Lavender3, Rose4), gradient2_1 = listOf(Shadow4, Shadow11), gradient2_2 = listOf(Ocean3, Shadow3), + gradient2_3 = listOf(Lavender3, Rose2), + tornado1 = listOf(Shadow4, Ocean3), isDark = false ) private val DarkColorPalette = JetsnackColors( brand = Shadow1, + brandSecondary = Ocean2, uiBackground = Neutral8, uiBorder = Neutral3, uiFloated = FunctionalDarkGrey, @@ -73,7 +71,9 @@ private val DarkColorPalette = JetsnackColors( gradient3_1 = listOf(Shadow9, Ocean7, Shadow5), gradient3_2 = listOf(Rose8, Lavender7, Rose11), gradient2_1 = listOf(Ocean3, Shadow3), - gradient2_2 = listOf(Ocean7, Shadow7), + gradient2_2 = listOf(Ocean4, Shadow2), + gradient2_3 = listOf(Lavender3, Rose3), + tornado1 = listOf(Shadow4, Ocean3), isDark = true ) @@ -84,16 +84,9 @@ fun JetsnackTheme( ) { val colors = if (darkTheme) DarkColorPalette else LightColorPalette - val sysUiController = SysUiController.current - onCommit(sysUiController, colors.uiBackground) { - sysUiController.setSystemBarsColor( - color = colors.uiBackground.copy(alpha = AlphaNearOpaque) - ) - } - ProvideJetsnackColors(colors) { MaterialTheme( - colors = debugColors(darkTheme), + colorScheme = debugColors(darkTheme), typography = Typography, shapes = Shapes, content = content @@ -104,153 +97,98 @@ fun JetsnackTheme( object JetsnackTheme { val colors: JetsnackColors @Composable - get() = AmbientJetsnackColors.current + get() = LocalJetsnackColors.current } /** * Jetsnack custom Color Palette */ -@Stable -class JetsnackColors( - gradient6_1: List<Color>, - gradient6_2: List<Color>, - gradient3_1: List<Color>, - gradient3_2: List<Color>, - gradient2_1: List<Color>, - gradient2_2: List<Color>, - brand: Color, - uiBackground: Color, - uiBorder: Color, - uiFloated: Color, - interactivePrimary: List<Color> = gradient2_1, - interactiveSecondary: List<Color> = gradient2_2, - interactiveMask: List<Color> = gradient6_1, - textPrimary: Color = brand, - textSecondary: Color, - textHelp: Color, - textInteractive: Color, - textLink: Color, - iconPrimary: Color = brand, - iconSecondary: Color, - iconInteractive: Color, - iconInteractiveInactive: Color, - error: Color, - notificationBadge: Color = error, - isDark: Boolean -) { - var gradient6_1 by mutableStateOf(gradient6_1) - private set - var gradient6_2 by mutableStateOf(gradient6_2) - private set - var gradient3_1 by mutableStateOf(gradient3_1) - private set - var gradient3_2 by mutableStateOf(gradient3_2) - private set - var gradient2_1 by mutableStateOf(gradient2_1) - private set - var gradient2_2 by mutableStateOf(gradient2_2) - private set - var brand by mutableStateOf(brand) - private set - var uiBackground by mutableStateOf(uiBackground) - private set - var uiBorder by mutableStateOf(uiBorder) - private set - var uiFloated by mutableStateOf(uiFloated) - private set - var interactivePrimary by mutableStateOf(interactivePrimary) - private set - var interactiveSecondary by mutableStateOf(interactiveSecondary) - private set - var interactiveMask by mutableStateOf(interactiveMask) - private set - var textPrimary by mutableStateOf(textPrimary) - private set - var textSecondary by mutableStateOf(textSecondary) - private set - var textHelp by mutableStateOf(textHelp) - private set - var textInteractive by mutableStateOf(textInteractive) - private set - var textLink by mutableStateOf(textLink) - private set - var iconPrimary by mutableStateOf(iconPrimary) - private set - var iconSecondary by mutableStateOf(iconSecondary) - private set - var iconInteractive by mutableStateOf(iconInteractive) - private set - var iconInteractiveInactive by mutableStateOf(iconInteractiveInactive) - private set - var error by mutableStateOf(error) - private set - var notificationBadge by mutableStateOf(notificationBadge) - private set - var isDark by mutableStateOf(isDark) - private set - - fun update(other: JetsnackColors) { - gradient6_1 = other.gradient6_1 - gradient6_2 = other.gradient6_2 - gradient3_1 = other.gradient3_1 - gradient3_2 = other.gradient3_2 - gradient2_1 = other.gradient2_1 - gradient2_2 = other.gradient2_2 - brand = other.brand - uiBackground = other.uiBackground - uiBorder = other.uiBorder - uiFloated = other.uiFloated - interactivePrimary = other.interactivePrimary - interactiveSecondary = other.interactiveSecondary - interactiveMask = other.interactiveMask - textPrimary = other.textPrimary - textSecondary = other.textSecondary - textHelp = other.textHelp - textInteractive = other.textInteractive - textLink = other.textLink - iconPrimary = other.iconPrimary - iconSecondary = other.iconSecondary - iconInteractive = other.iconInteractive - iconInteractiveInactive = other.iconInteractiveInactive - error = other.error - notificationBadge = other.notificationBadge - isDark = other.isDark - } -} +@Immutable +data class JetsnackColors( + val gradient6_1: List<Color>, + val gradient6_2: List<Color>, + val gradient3_1: List<Color>, + val gradient3_2: List<Color>, + val gradient2_1: List<Color>, + val gradient2_2: List<Color>, + val gradient2_3: List<Color>, + val brand: Color, + val brandSecondary: Color, + val uiBackground: Color, + val uiBorder: Color, + val uiFloated: Color, + val interactivePrimary: List<Color> = gradient2_1, + val interactiveSecondary: List<Color> = gradient2_2, + val interactiveMask: List<Color> = gradient6_1, + val textPrimary: Color = brand, + val textSecondary: Color, + val textHelp: Color, + val textInteractive: Color, + val textLink: Color, + val tornado1: List<Color>, + val iconPrimary: Color = brand, + val iconSecondary: Color, + val iconInteractive: Color, + val iconInteractiveInactive: Color, + val error: Color, + val notificationBadge: Color = error, + val isDark: Boolean +) @Composable fun ProvideJetsnackColors( colors: JetsnackColors, content: @Composable () -> Unit ) { - val colorPalette = remember { colors } - colorPalette.update(colors) - Providers(AmbientJetsnackColors provides colorPalette, content = content) + CompositionLocalProvider(LocalJetsnackColors provides colors, content = content) } -private val AmbientJetsnackColors = staticAmbientOf<JetsnackColors> { +private val LocalJetsnackColors = staticCompositionLocalOf<JetsnackColors> { error("No JetsnackColorPalette provided") } /** * A Material [Colors] implementation which sets all colors to [debugColor] to discourage usage of - * [MaterialTheme.colors] in preference to [JetsnackTheme.colors]. + * [MaterialTheme.colorScheme] in preference to [JetsnackTheme.colors]. */ fun debugColors( darkTheme: Boolean, debugColor: Color = Color.Magenta -) = Colors( +) = ColorScheme( primary = debugColor, - primaryVariant = debugColor, - secondary = debugColor, - secondaryVariant = debugColor, - background = debugColor, - surface = debugColor, - error = debugColor, onPrimary = debugColor, + primaryContainer = debugColor, + onPrimaryContainer = debugColor, + inversePrimary = debugColor, + secondary = debugColor, onSecondary = debugColor, + secondaryContainer = debugColor, + onSecondaryContainer = debugColor, + tertiary = debugColor, + onTertiary = debugColor, + tertiaryContainer = debugColor, + onTertiaryContainer = debugColor, + background = debugColor, onBackground = debugColor, + surface = debugColor, onSurface = debugColor, + surfaceVariant = debugColor, + onSurfaceVariant = debugColor, + surfaceTint = debugColor, + inverseSurface = debugColor, + inverseOnSurface = debugColor, + error = debugColor, onError = debugColor, - isLight = !darkTheme + errorContainer = debugColor, + onErrorContainer = debugColor, + outline = debugColor, + outlineVariant = debugColor, + scrim = debugColor, + surfaceBright = debugColor, + surfaceDim = debugColor, + surfaceContainer = debugColor, + surfaceContainerHigh = debugColor, + surfaceContainerHighest = debugColor, + surfaceContainerLow = debugColor, + surfaceContainerLowest = debugColor, ) diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Type.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Type.kt index 47e7b68fc4..bda6b38704 100644 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Type.kt +++ b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/theme/Type.kt @@ -16,108 +16,108 @@ package com.example.jetsnack.ui.theme -import androidx.compose.material.Typography +import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.font.font -import androidx.compose.ui.text.font.fontFamily import androidx.compose.ui.unit.sp import com.example.jetsnack.R -private val Montserrat = fontFamily( - font(R.font.montserrat_light, FontWeight.Light), - font(R.font.montserrat_regular, FontWeight.Normal), - font(R.font.montserrat_medium, FontWeight.Medium), - font(R.font.montserrat_semibold, FontWeight.SemiBold) +private val Montserrat = FontFamily( + Font(R.font.montserrat_light, FontWeight.Light), + Font(R.font.montserrat_regular, FontWeight.Normal), + Font(R.font.montserrat_medium, FontWeight.Medium), + Font(R.font.montserrat_semibold, FontWeight.SemiBold) ) -private val Karla = fontFamily( - font(R.font.karla_regular, FontWeight.Normal), - font(R.font.karla_bold, FontWeight.Bold) +private val Karla = FontFamily( + Font(R.font.karla_regular, FontWeight.Normal), + Font(R.font.karla_bold, FontWeight.Bold) ) val Typography = Typography( - h1 = TextStyle( + displayLarge = TextStyle( fontFamily = Montserrat, fontSize = 96.sp, fontWeight = FontWeight.Light, lineHeight = 117.sp, letterSpacing = (-1.5).sp ), - h2 = TextStyle( + displayMedium = TextStyle( fontFamily = Montserrat, fontSize = 60.sp, fontWeight = FontWeight.Light, lineHeight = 73.sp, letterSpacing = (-0.5).sp ), - h3 = TextStyle( + displaySmall = TextStyle( fontFamily = Montserrat, fontSize = 48.sp, fontWeight = FontWeight.Normal, lineHeight = 59.sp ), - h4 = TextStyle( + headlineMedium = TextStyle( fontFamily = Montserrat, fontSize = 30.sp, fontWeight = FontWeight.SemiBold, lineHeight = 37.sp ), - h5 = TextStyle( + headlineSmall = TextStyle( fontFamily = Montserrat, fontSize = 24.sp, fontWeight = FontWeight.SemiBold, lineHeight = 29.sp ), - h6 = TextStyle( + titleLarge = TextStyle( fontFamily = Montserrat, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, lineHeight = 24.sp ), - subtitle1 = TextStyle( + titleMedium = TextStyle( fontFamily = Montserrat, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, lineHeight = 24.sp, letterSpacing = 0.15.sp ), - subtitle2 = TextStyle( + titleSmall = TextStyle( fontFamily = Karla, fontSize = 14.sp, fontWeight = FontWeight.Bold, lineHeight = 24.sp, letterSpacing = 0.1.sp ), - body1 = TextStyle( + bodyLarge = TextStyle( fontFamily = Karla, fontSize = 16.sp, fontWeight = FontWeight.Normal, lineHeight = 28.sp, letterSpacing = 0.15.sp ), - body2 = TextStyle( + bodyMedium = TextStyle( fontFamily = Montserrat, fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp, letterSpacing = 0.25.sp ), - button = TextStyle( + labelLarge = TextStyle( fontFamily = Montserrat, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, lineHeight = 16.sp, letterSpacing = 1.25.sp ), - caption = TextStyle( + bodySmall = TextStyle( fontFamily = Karla, fontSize = 12.sp, fontWeight = FontWeight.Bold, lineHeight = 16.sp, letterSpacing = 0.4.sp ), - overline = TextStyle( + labelSmall = TextStyle( fontFamily = Montserrat, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Navigation.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Navigation.kt deleted file mode 100644 index e6d85afde9..0000000000 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/Navigation.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetsnack.ui.utils - -import android.os.Parcelable -import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.runtime.savedinstancestate.listSaver -import androidx.compose.runtime.toMutableStateList - -/** - * A simple navigator which maintains a back stack. - */ -class Navigator<T : Parcelable> private constructor( - initialBackStack: List<T>, - backDispatcher: OnBackPressedDispatcher -) { - constructor( - initial: T, - backDispatcher: OnBackPressedDispatcher - ) : this(listOf(initial), backDispatcher) - - private val backStack = initialBackStack.toMutableStateList() - private val backCallback = object : OnBackPressedCallback(canGoBack()) { - override fun handleOnBackPressed() { - back() - } - }.also { callback -> - backDispatcher.addCallback(callback) - } - val current: T get() = backStack.last() - - fun back() { - backStack.removeAt(backStack.lastIndex) - backCallback.isEnabled = canGoBack() - } - - fun navigate(destination: T) { - backStack += destination - backCallback.isEnabled = canGoBack() - } - - private fun canGoBack(): Boolean = backStack.size > 1 - - companion object { - /** - * Serialize the back stack to save to instance state. - */ - fun <T : Parcelable> saver(backDispatcher: OnBackPressedDispatcher) = - listSaver<Navigator<T>, T>( - save = { navigator -> navigator.backStack.toList() }, - restore = { backstack -> Navigator(backstack, backDispatcher) } - ) - } -} diff --git a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/SystemUi.kt b/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/SystemUi.kt deleted file mode 100644 index 792bc22795..0000000000 --- a/Jetsnack/app/src/main/java/com/example/jetsnack/ui/utils/SystemUi.kt +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetsnack.ui.utils - -import android.os.Build -import android.view.View -import android.view.Window -import androidx.compose.runtime.staticAmbientOf -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.toArgb - -interface SystemUiController { - fun setStatusBarColor( - color: Color, - darkIcons: Boolean = color.luminance() > 0.5f, - transformColorForLightContent: (Color) -> Color = BlackScrimmed - ) - - fun setNavigationBarColor( - color: Color, - darkIcons: Boolean = color.luminance() > 0.5f, - transformColorForLightContent: (Color) -> Color = BlackScrimmed - ) - - fun setSystemBarsColor( - color: Color, - darkIcons: Boolean = color.luminance() > 0.5f, - transformColorForLightContent: (Color) -> Color = BlackScrimmed - ) -} - -fun SystemUiController(window: Window): SystemUiController { - return SystemUiControllerImpl(window) -} - -/** - * A helper class for setting the navigation and status bar colors for a [Window], gracefully - * degrading behavior based upon API level. - */ -private class SystemUiControllerImpl(private val window: Window) : SystemUiController { - - /** - * Set the status bar color. - * - * @param color The **desired** [Color] to set. This may require modification if running on an - * API level that only supports white status bar icons. - * @param darkIcons Whether dark status bar icons would be preferable. Only available on - * API 23+. - * @param transformColorForLightContent A lambda which will be invoked to transform [color] if - * dark icons were requested but are not available. Defaults to applying a black scrim. - */ - override fun setStatusBarColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) { - val statusBarColor = when { - darkIcons && Build.VERSION.SDK_INT < 23 -> transformColorForLightContent(color) - else -> color - } - window.statusBarColor = statusBarColor.toArgb() - - if (Build.VERSION.SDK_INT >= 23) { - @Suppress("DEPRECATION") - if (darkIcons) { - window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or - View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - } else { - window.decorView.systemUiVisibility = window.decorView.systemUiVisibility and - View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() - } - } - } - - /** - * Set the navigation bar color. - * - * @param color The **desired** [Color] to set. This may require modification if running on an - * API level that only supports white navigation bar icons. Additionally this will be ignored - * and [Color.Transparent] will be used on API 29+ where gesture navigation is preferred or the - * system UI automatically applies background protection in other navigation modes. - * @param darkIcons Whether dark navigation bar icons would be preferable. Only available on - * API 26+. - * @param transformColorForLightContent A lambda which will be invoked to transform [color] if - * dark icons were requested but are not available. Defaults to applying a black scrim. - */ - override fun setNavigationBarColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) { - val navBarColor = when { - Build.VERSION.SDK_INT >= 29 -> Color.Transparent // For gesture nav - darkIcons && Build.VERSION.SDK_INT < 26 -> transformColorForLightContent(color) - else -> color - } - window.navigationBarColor = navBarColor.toArgb() - - if (Build.VERSION.SDK_INT >= 26) { - @Suppress("DEPRECATION") - if (darkIcons) { - window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or - View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR - } else { - window.decorView.systemUiVisibility = window.decorView.systemUiVisibility and - View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() - } - } - } - - /** - * Set the status and navigation bars to [color]. - * - * @see setStatusBarColor - * @see setNavigationBarColor - */ - override fun setSystemBarsColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) { - setStatusBarColor(color, darkIcons, transformColorForLightContent) - setNavigationBarColor(color, darkIcons, transformColorForLightContent) - } -} - -/** - * An [androidx.compose.runtime.Ambient] holding the current [SysUiController]. Defaults to a - * no-op controller; consumers should [provide][androidx.compose.runtime.Providers] a real one. - */ -val SysUiController = staticAmbientOf<SystemUiController> { - FakeSystemUiController -} - -private val BlackScrim = Color(0f, 0f, 0f, 0.2f) // 20% opaque black -private val BlackScrimmed: (Color) -> Color = { original -> - BlackScrim.compositeOver(original) -} - -/** - * A fake implementation, useful as a default or used in Previews. - */ -private object FakeSystemUiController : SystemUiController { - override fun setStatusBarColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) = Unit - - override fun setNavigationBarColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) = Unit - - override fun setSystemBarsColor( - color: Color, - darkIcons: Boolean, - transformColorForLightContent: (Color) -> Color - ) = Unit -} diff --git a/Jetsnack/app/src/main/res/drawable-night/empty_state_search.xml b/Jetsnack/app/src/main/res/drawable-night/empty_state_search.xml index 1cd280e07a..2ed8ee6032 100644 --- a/Jetsnack/app/src/main/res/drawable-night/empty_state_search.xml +++ b/Jetsnack/app/src/main/res/drawable-night/empty_state_search.xml @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- +<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/almonds.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/almonds.jpg new file mode 100644 index 0000000000..0f1eef57e3 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/almonds.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/apple_chips.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/apple_chips.jpg new file mode 100644 index 0000000000..37c805d169 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/apple_chips.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/apple_juice.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/apple_juice.jpg new file mode 100644 index 0000000000..d519dbcd46 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/apple_juice.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/apple_pie.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/apple_pie.jpg new file mode 100644 index 0000000000..41096b2216 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/apple_pie.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/apple_sauce.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/apple_sauce.jpg new file mode 100644 index 0000000000..7f5331ee24 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/apple_sauce.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/apples.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/apples.jpg new file mode 100644 index 0000000000..cc729f1646 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/apples.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/cheese.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/cheese.jpg new file mode 100644 index 0000000000..c5c6dce61c Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/cheese.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/chips.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/chips.jpg new file mode 100644 index 0000000000..04d5fe2cd3 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/chips.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/cupcake.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/cupcake.jpg new file mode 100644 index 0000000000..42e766d843 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/cupcake.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/desserts.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/desserts.jpg new file mode 100644 index 0000000000..6d44990765 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/desserts.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/donut.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/donut.jpg new file mode 100644 index 0000000000..076896a812 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/donut.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/eclair.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/eclair.jpg new file mode 100644 index 0000000000..5601780345 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/eclair.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/froyo.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/froyo.jpg new file mode 100644 index 0000000000..e1bb068cc9 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/froyo.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/fruit.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/fruit.jpg new file mode 100644 index 0000000000..4122473184 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/fruit.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/gingerbread.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/gingerbread.jpg new file mode 100644 index 0000000000..ac069de103 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/gingerbread.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/gluten_free.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/gluten_free.jpg new file mode 100644 index 0000000000..0745457a36 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/gluten_free.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/grapes.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/grapes.jpg new file mode 100644 index 0000000000..3b573787cf Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/grapes.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/honeycomb.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/honeycomb.jpg new file mode 100644 index 0000000000..ea632bd25d Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/honeycomb.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/ice_cream_sandwich.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/ice_cream_sandwich.jpg new file mode 100644 index 0000000000..fd77631e9a Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/ice_cream_sandwich.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/jelly_bean.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/jelly_bean.jpg new file mode 100644 index 0000000000..84a10208c9 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/jelly_bean.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/kitkat.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/kitkat.jpg new file mode 100644 index 0000000000..75b2e44abb Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/kitkat.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/kiwi.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/kiwi.jpg new file mode 100644 index 0000000000..2197fbd48f Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/kiwi.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/lollipop.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/lollipop.jpg new file mode 100644 index 0000000000..98d1db7d2f Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/lollipop.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/mango.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/mango.jpg new file mode 100644 index 0000000000..717773d7b5 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/mango.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/marshmallow.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/marshmallow.jpg new file mode 100644 index 0000000000..cdc1159226 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/marshmallow.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/nougat.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/nougat.jpg new file mode 100644 index 0000000000..1a844d9b9f Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/nougat.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/nuts.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/nuts.jpg new file mode 100644 index 0000000000..03556767ec Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/nuts.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/oreo.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/oreo.jpg new file mode 100644 index 0000000000..cf2c3e534c Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/oreo.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/organic.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/organic.jpg new file mode 100644 index 0000000000..2847abf4f0 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/organic.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/paleo.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/paleo.jpg new file mode 100644 index 0000000000..750fcd58e7 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/paleo.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/pie.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/pie.jpg new file mode 100644 index 0000000000..439c18cfbb Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/pie.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/placeholder.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/placeholder.jpg new file mode 100644 index 0000000000..31e05faacb Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/placeholder.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/popcorn.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/popcorn.jpg new file mode 100644 index 0000000000..02713ffdbf Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/popcorn.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/pretzels.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/pretzels.jpg new file mode 100644 index 0000000000..d31d4aa4eb Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/pretzels.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/smoothies.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/smoothies.jpg new file mode 100644 index 0000000000..f2eaa31bf1 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/smoothies.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-nodpi/vegan.jpg b/Jetsnack/app/src/main/res/drawable-nodpi/vegan.jpg new file mode 100644 index 0000000000..29276a68b7 Binary files /dev/null and b/Jetsnack/app/src/main/res/drawable-nodpi/vegan.jpg differ diff --git a/Jetsnack/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Jetsnack/app/src/main/res/drawable-v26/ic_launcher_foreground.xml index 6e2c52363d..86d0b4a931 100644 --- a/Jetsnack/app/src/main/res/drawable-v26/ic_launcher_foreground.xml +++ b/Jetsnack/app/src/main/res/drawable-v26/ic_launcher_foreground.xml @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- +<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except @@ -18,22 +17,26 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - <path - android:pathData="M70,36v36h-3.6L66.4,57.6L61,57.6L61,43.2c0,-3.168 4.032,-7.2 9,-7.2zM40.6,36v12.6h3.6L44.2,36h3.6v12.6h3.6L51.4,36L55,36v12.6c0,3.978 -3.222,7.2 -7.2,7.2L47.8,72h-3.6L44.2,55.8a7.198,7.198 0,0 1,-7.2 -7.2L37,36h3.6z" - android:fillColor="#fff" - android:fillType="evenOdd"/> - <path - android:pathData="M0,0h108v108H0z" - android:fillType="evenOdd"> - <aapt:attr name="android:fillColor"> - <gradient - android:gradientRadius="71.14824" - android:centerX="29.74104" - android:centerY="29.68488" - android:type="radial"> - <item android:offset="0" android:color="#19ffffff"/> - <item android:offset="1" android:color="#00ffffff"/> - </gradient> - </aapt:attr> - </path> + <path + android:fillColor="#fff" + android:fillType="evenOdd" + android:pathData="M70,36v36h-3.6L66.4,57.6L61,57.6L61,43.2c0,-3.168 4.032,-7.2 9,-7.2zM40.6,36v12.6h3.6L44.2,36h3.6v12.6h3.6L51.4,36L55,36v12.6c0,3.978 -3.222,7.2 -7.2,7.2L47.8,72h-3.6L44.2,55.8a7.198,7.198 0,0 1,-7.2 -7.2L37,36h3.6z" /> + <path + android:fillType="evenOdd" + android:pathData="M0,0h108v108H0z"> + <aapt:attr name="android:fillColor"> + <gradient + android:centerX="29.74104" + android:centerY="29.68488" + android:gradientRadius="71.14824" + android:type="radial"> + <item + android:color="#19ffffff" + android:offset="0" /> + <item + android:color="#00ffffff" + android:offset="1" /> + </gradient> + </aapt:attr> + </path> </vector> diff --git a/Jetsnack/app/src/main/res/drawable/empty_state_search.xml b/Jetsnack/app/src/main/res/drawable/empty_state_search.xml index e2fa0cdac8..94095221cf 100644 --- a/Jetsnack/app/src/main/res/drawable/empty_state_search.xml +++ b/Jetsnack/app/src/main/res/drawable/empty_state_search.xml @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- +<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except diff --git a/Jetsnack/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetsnack/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 8ce0e286d1..ed2a1d867e 100644 --- a/Jetsnack/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/Jetsnack/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- +<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except @@ -15,4 +14,5 @@ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/shadow_5" /> <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> </adaptive-icon> diff --git a/Jetsnack/app/src/main/res/values-night/themes.xml b/Jetsnack/app/src/main/res/values-night/themes.xml index d444fdea51..d5eda5c05c 100644 --- a/Jetsnack/app/src/main/res/values-night/themes.xml +++ b/Jetsnack/app/src/main/res/values-night/themes.xml @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- +<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except diff --git a/Jetsnack/app/src/main/res/values/colors.xml b/Jetsnack/app/src/main/res/values/colors.xml index 1336a20316..91ae7258f2 100644 --- a/Jetsnack/app/src/main/res/values/colors.xml +++ b/Jetsnack/app/src/main/res/values/colors.xml @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- +<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except diff --git a/Jetsnack/app/src/main/res/values/strings.xml b/Jetsnack/app/src/main/res/values/strings.xml index 0350109f60..120984f545 100644 --- a/Jetsnack/app/src/main/res/values/strings.xml +++ b/Jetsnack/app/src/main/res/values/strings.xml @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- +<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except @@ -14,6 +13,7 @@ --> <resources> <string name="app_name">Jetsnack</string> + <string name="label_back">Back</string> <!-- Home Tabs --> <string name="home_feed">Home</string> @@ -21,15 +21,21 @@ <string name="home_cart">My Cart</string> <string name="home_profile">Profile</string> + <!-- Home --> + <string name="label_filters">Filters</string> + <string name="label_select_delivery">Select delivery address</string> + <!-- Search --> <string name="search_jetsnack">Search Jetsnack</string> <string name="search_no_matches">No matches for “%1s”</string> <string name="search_no_matches_retry">Try broadening your search</string> <string name="search_count">%1d items</string> + <string name="label_add">Add to cart</string> + <string name="label_search">Perform search</string> <!-- Snack Detail --> <string name="detail_header">Details</string> - <string name="detail_placeholder">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud...</string> + <string name="detail_placeholder">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut tempus, sem vitae convallis imperdiet, lectus nunc pharetra diam, ac rhoncus quam eros eu risus. Nulla pulvinar condimentum erat, pulvinar tempus turpis blandit ut. Etiam sed ipsum sed lacus eleifend hendrerit eu quis quam. Etiam ligula eros, finibus vestibulum tortor ac, ultrices accumsan dolor. Vivamus vel nisl a libero lobortis posuere. Aenean facilisis nibh vel ultrices bibendum. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Suspendisse ac est vitae lacus commodo efficitur at ut massa. Etiam vestibulum sit amet sapien sed varius. Aliquam non ipsum imperdiet, pulvinar enim nec, mollis risus. Fusce id tincidunt nisl.</string> <string name="ingredients">Ingredients</string> <string name="ingredients_list">Vanilla, Almond Flour, Eggs, Butter, Cream, Sugar</string> <string name="quantity">Qty</string> @@ -46,4 +52,28 @@ <string name="cart_shipping_label">Shipping & Handling</string> <string name="cart_total_label">Total</string> <string name="cart_checkout">Checkout</string> + <string name="cart_increase_error">There was an error and the quantity couldn\'t be increased. Please try again.</string> + <string name="cart_decrease_error">There was an error and the quantity couldn\'t be decreased. Please try again.</string> + <string name="label_remove">Remove item</string> + + <!-- Quantity Selector --> + <string name="label_increase">Increase</string> + <string name="label_decrease">Decrease</string> + <string name="work_in_progress">This is currently work in progress</string> + <string name="grab_beverage">Grab a beverage and check back later!</string> + <string name="see_more">SEE MORE</string> + <string name="see_less">SEE LESS</string> + <string name="remove_item">Remove Item</string> + <string name="reset">Reset</string> + <string name="sort">Sort</string> + <string name="price">Price</string> + <string name="category">Category</string> + <string name="max_calories">Max Calories</string> + <string name="lifestyle">LifeStyle</string> + <string name="per_serving">per serving</string> + <string name="android_favorites">Android\'s Favorite (default)</string> + <string name="rating">Rating</string> + <string name="alphabetical">Alphabetical</string> + <string name="close">Close</string> + </resources> diff --git a/Jetsnack/app/src/main/res/values/themes.xml b/Jetsnack/app/src/main/res/values/themes.xml index 12347755da..14776251c4 100644 --- a/Jetsnack/app/src/main/res/values/themes.xml +++ b/Jetsnack/app/src/main/res/values/themes.xml @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- +<?xml version="1.0" encoding="utf-8"?><!-- Copyright 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except @@ -17,10 +16,13 @@ <style name="Theme.Jetsnack" parent="Theme.Material.DayNight.NoActionBar"> <item name="android:colorPrimary">#ff00ff</item> <item name="android:colorAccent">#ff00ff</item> - <item name="android:statusBarColor">@android:color/transparent</item> - <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:dialogTheme">@style/Theme.DialogFullScreen</item> </style> <style name="Theme.Material.DayNight.NoActionBar" parent="@android:style/Theme.Material.Light.NoActionBar" /> + <style name="Theme.DialogFullScreen" parent="Theme.Material.DayNight.NoActionBar"> + <item name="android:windowMinWidthMajor">100%</item> + <item name="android:windowMinWidthMinor">100%</item> + </style> </resources> diff --git a/Jetsnack/build.gradle b/Jetsnack/build.gradle deleted file mode 100644 index 54d3cec17a..0000000000 --- a/Jetsnack/build.gradle +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.jetsnack.buildsrc.Libs -import com.example.jetsnack.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.8.2' -} - -subprojects { - repositories { - google() - jcenter() - - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { - url "https://androidx.dev/snapshots/builds/${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" - } - } - - maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - jvmTarget = '1.8' - allWarningsAsErrors = true - freeCompilerArgs += '-Xallow-jvm-ir-dependencies' - // Opt-in to experimental compose APIs - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - // Enable experimental coroutines APIs, including collectAsState() - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - } - } - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - - ktlint(Versions.ktlint).userData([android: "true"]) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } -} diff --git a/Jetsnack/build.gradle.kts b/Jetsnack/build.gradle.kts new file mode 100644 index 0000000000..30355ffe44 --- /dev/null +++ b/Jetsnack/build.gradle.kts @@ -0,0 +1,73 @@ +/* + * 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 + alias(libs.plugins.spotless) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure<com.diffplug.gradle.spotless.SpotlessExtension> { + ratchetFrom = "origin/main" + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt") + ktlint().editorConfigOverride( + mapOf( + "ktlint_code_style" to "android_studio", + "ij_kotlin_allow_trailing_comma" to true, + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + // 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"), "(^(?![\\/ ]\\*).*$)") + } + kotlinGradle { + target("*.gradle.kts") + ktlint() + } + } +} diff --git a/Jetsnack/buildSrc/build.gradle.kts b/Jetsnack/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Jetsnack/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Jetsnack/buildSrc/src/main/java/com/example/jetsnack/buildsrc/Dependencies.kt b/Jetsnack/buildSrc/src/main/java/com/example/jetsnack/buildsrc/Dependencies.kt deleted file mode 100644 index b9da6f12a6..0000000000 --- a/Jetsnack/buildSrc/src/main/java/com/example/jetsnack/buildsrc/Dependencies.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.jetsnack.buildsrc - -object Versions { - const val ktlint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha04" - const val junit = "junit:junit:4.13" - - object Accompanist { - private const val version = "0.4.2" - const val coil = "dev.chrisbanes.accompanist:accompanist-coil:$version" - const val insets = "dev.chrisbanes.accompanist:accompanist-insets:$version" - } - - object Kotlin { - private const val version = "1.4.21" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.1" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object AndroidX { - const val coreKtx = "androidx.core:core-ktx:1.5.0-beta01" - - object Compose { - const val snapshot = "" - const val version = "1.0.0-alpha10" - - const val foundation = "androidx.compose.foundation:foundation:${version}" - const val layout = "androidx.compose.foundation:foundation-layout:${version}" - const val ui = "androidx.compose.ui:ui:${version}" - const val uiUtil = "androidx.compose.ui:ui-util:${version}" - const val runtime = "androidx.compose.runtime:runtime:${version}" - const val material = "androidx.compose.material:material:${version}" - const val animation = "androidx.compose.animation:animation:${version}" - const val tooling = "androidx.compose.ui:ui-tooling:${version}" - const val iconsExtended = "androidx.compose.material:material-icons-extended:$version" - } - - object Test { - private const val version = "1.2.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - } -} diff --git a/Jetsnack/buildscripts/toml-updater-config.gradle b/Jetsnack/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/Jetsnack/buildscripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/Jetsnack/debug.keystore b/Jetsnack/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Jetsnack/debug.keystore and /dev/null differ diff --git a/Jetsnack/debug_2.keystore b/Jetsnack/debug_2.keystore new file mode 100644 index 0000000000..b42c971788 Binary files /dev/null and b/Jetsnack/debug_2.keystore differ diff --git a/Jetsnack/gradle.properties b/Jetsnack/gradle.properties index b2d834ce9c..9299bc6d0f 100644 --- a/Jetsnack/gradle.properties +++ b/Jetsnack/gradle.properties @@ -37,6 +37,3 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Jetsnack/gradle/libs.versions.toml b/Jetsnack/gradle/libs.versions.toml new file mode 100644 index 0000000000..29943df2e6 --- /dev/null +++ b/Jetsnack/gradle/libs.versions.toml @@ -0,0 +1,177 @@ +##### +# This file is duplicated to individual samples from the global scripts/libs.versions.toml +# Do not add a dependency to an individual sample, edit the global version instead. +##### +[versions] +accompanist = "0.37.2" +android-material3 = "1.13.0-alpha13" +androidGradlePlugin = "8.9.2" +androidx-activity-compose = "1.10.1" +androidx-appcompat = "1.7.0" +androidx-compose-bom = "2025.04.01" +androidx-constraintlayout = "1.1.0" +androidx-core-splashscreen = "1.0.1" +androidx-corektx = "1.16.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.9" +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.2" +google-maps = "18.2.0" +gradle-versions = "0.52.0" +hilt = "2.56.2" +hiltExt = "1.2.0" +horologist = "0.6.23" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.1.20" +kotlinx-serialization-json = "1.7.3" +kotlinx_immutable = "0.3.8" +ksp = "2.1.20-2.0.0" +maps-compose = "3.1.1" +# @keep +minSdk = "21" +okhttp = "4.12.0" +play-services-wearable = "18.1.0" +robolectric = "4.14.1" +roborazzi = "1.43.1" +rome = "2.1.0" +room = "2.7.1" +secrets = "2.0.1" +spotless = "7.0.3" +# @keep +targetSdk = "33" +version-catalog-update = "1.0.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = "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" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/Jetsnack/gradle/wrapper/gradle-wrapper.jar b/Jetsnack/gradle/wrapper/gradle-wrapper.jar index e708b1c023..7454180f2a 100644 Binary files a/Jetsnack/gradle/wrapper/gradle-wrapper.jar and b/Jetsnack/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Jetsnack/gradle/wrapper/gradle-wrapper.properties b/Jetsnack/gradle/wrapper/gradle-wrapper.properties index 089b9f390f..d6c8bc7bf8 100644 --- a/Jetsnack/gradle/wrapper/gradle-wrapper.properties +++ b/Jetsnack/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,19 @@ +# Copyright 2023 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-rc-1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/Jetsnack/gradlew b/Jetsnack/gradlew index 4f906e0c81..744e882ed5 100755 --- a/Jetsnack/gradlew +++ b/Jetsnack/gradlew @@ -72,7 +72,7 @@ case "`uname`" in Darwin* ) darwin=true ;; - MINGW* ) + MSYS* | MINGW* ) msys=true ;; NONSTOP* ) diff --git a/Jetsnack/screenshots/screenshots.png b/Jetsnack/screenshots/screenshots.png new file mode 100644 index 0000000000..f1e2707434 Binary files /dev/null and b/Jetsnack/screenshots/screenshots.png differ diff --git a/Jetsnack/settings.gradle b/Jetsnack/settings.gradle deleted file mode 100644 index 62384f2525..0000000000 --- a/Jetsnack/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -include ':app' -rootProject.name = "Jetsnack" \ No newline at end of file diff --git a/Jetsnack/settings.gradle.kts b/Jetsnack/settings.gradle.kts new file mode 100644 index 0000000000..3bc8533030 --- /dev/null +++ b/Jetsnack/settings.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +val snapshotVersion : String? = System.getenv("COMPOSE_SNAPSHOT_ID") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + maven { url = uri("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.0.0-RC2-200/") } + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + snapshotVersion?.let { + println("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") + maven { url = uri("https://androidx.dev/snapshots/builds/$it/artifacts/repository/") } + maven { url = uri("https://maven.pkg.jetbrains.space/kotlin/p/kotlin/dev/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.0.0-RC2-200/") } + } + + google() + mavenCentral() + } +} +rootProject.name = "Jetsnack" +include(":app") diff --git a/Jetsurvey/.gitignore b/Jetsurvey/.gitignore deleted file mode 100644 index dcd0ee3b86..0000000000 --- a/Jetsurvey/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -*.iml -.gradle -/local.properties -/.idea -.DS_Store -/build -/captures -.externalNativeBuild -/projectFilesBackup diff --git a/Jetsurvey/.google/packaging.yaml b/Jetsurvey/.google/packaging.yaml deleted file mode 100644 index 84aff54914..0000000000 --- a/Jetsurvey/.google/packaging.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (C) 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# GOOGLE SAMPLE PACKAGING DATA -# -# This file is used by Google as part of our samples packaging process. -# End users may safely ignore this file. It has no relevance to other systems. ---- -status: PUBLISHED -technologies: [Android] -categories: [Compose] -languages: [Kotlin] -solutions: [Mobile] -github: android/compose-samples -level: BEGINNER -apiRefs: - - android:androidx.compose.Composable -license: apache2 diff --git a/Jetsurvey/README.md b/Jetsurvey/README.md deleted file mode 100644 index 6268539bdc..0000000000 --- a/Jetsurvey/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# Jetsurvey sample - -Jetsurvey is a sample survey app, built with -[Jetpack Compose](https://developer.android.com/jetpack/compose). The goal of the sample is to -showcase text input, validation and state capabilities of Compose. - -To try out these sample apps, you need to use the latest Canary version of Android Studio 4.2. -You can clone this repository or import the -project from Android Studio following the steps -[here](https://developer.android.com/jetpack/compose/setup#sample). - -Screenshots ------------ - <img src="screenshots/survey.gif" width="425"/> - -## Features - -This sample contains several screens: a welcome screen, where the user can enter their email, sign in and sign up screens and a survey screen. The app has light and dark themes. - -### App scaffolding - -Package [`com.example.compose.jetsurvey`][1] - -[`MainActivity`][2] is the application's entry point. Each screen is implemented inside a `Fragment` and [`MainActivity`][2] is the host `Activity` for all of the `Fragment`s. -The navigation between them uses the [Navigation library][3]. The screens and the navigation are defined in [`Navigation.kt`][4] - -[1]: app/src/main/java/com/example/compose/jetsurvey -[2]: app/src/main/java/com/example/compose/jetsurvey/MainActivity.kt -[3]: https://developer.android.com/guide/navigation -[4]: app/src/main/java/com/example/compose/jetsurvey/Navigation.kt - -### Sign in/sign up - -Package [`com.example.compose.jetsurvey.signinsignup`][5] - -This package contains 3 screens: -* Welcome -* Sign in -* Sign up - -To get to the sign up screen, enter an email that contains "signup". -These screens show how to create different custom composable functions, reused them across multiple screens and handle UI state. - -See how to: - -* Use `TextField`s -* Implement `TextField` validation across one `TextField` (e.g. email validation) and across multiple `TextFields` (e.g. password confirmation) -* Use a `Snackbar` -* Use different types of `Button`s: `TextButton`, `OutlinedButton` and `Button` - -[5]: app/src/main/java/com/example/compose/jetsurvey/signinsignup - -### Complete a survey - -Package [`com.example.compose.jetsurvey.survey`][6] - -This screen allows the user to fill out a survey, showing how to handle complex state. UI state is kept and restored on recompositions triggered by different reasons like a configuration change or a new question being displayed on the screen. - -See how to: - -* Use `RadioButton`s - for single item selection -* Use `Checkbox`es - for multi-item selection -* Use `Slider` - for picking a value from a range -* Use `Scaffold` - for screens with top bar, bottom bar and body -* Display a `DialogFragment` when requested from compose - -[6]: app/src/main/java/com/example/compose/jetsurvey/survey - -### Data - -The data in the sample is static, held in the `*Repository` classes. - -## Setup -The main [README](https://github.com/android/compose-samples/) has instructions on how to -setup this sample, and many others. - -## License - -``` -Copyright 2020 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` diff --git a/Jetsurvey/app/.gitignore b/Jetsurvey/app/.gitignore deleted file mode 100644 index 796b96d1c4..0000000000 --- a/Jetsurvey/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/Jetsurvey/app/build.gradle b/Jetsurvey/app/build.gradle deleted file mode 100644 index 8d063a4ca6..0000000000 --- a/Jetsurvey/app/build.gradle +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import com.example.compose.jetsurvey.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' -} - -android { - compileSdkVersion 30 - - defaultConfig { - applicationId "com.example.compose.jetsurvey" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - buildFeatures { - compose true - - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerVersion Libs.Kotlin.version - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } - - // TODO: Fix lint errors and remove - lintOptions { - abortOnError false - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - implementation Libs.AndroidX.coreKtx - implementation Libs.AndroidX.appcompat - implementation Libs.AndroidX.Navigation.fragment - implementation Libs.AndroidX.Navigation.uiKtx - implementation Libs.AndroidX.Material.material - implementation Libs.material - - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.materialIconsExtended - implementation Libs.AndroidX.Compose.tooling - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.runtimeLivedata - - implementation Libs.Accompanist.coil - - androidTestImplementation Libs.junit - androidTestImplementation Libs.AndroidX.Test.core - androidTestImplementation Libs.AndroidX.Test.espressoCore - androidTestImplementation Libs.AndroidX.Test.rules - androidTestImplementation Libs.AndroidX.Test.Ext.junit - androidTestImplementation Libs.AndroidX.Compose.uiTest -} diff --git a/Jetsurvey/app/proguard-rules.pro b/Jetsurvey/app/proguard-rules.pro deleted file mode 100644 index 4cb94585a0..0000000000 --- a/Jetsurvey/app/proguard-rules.pro +++ /dev/null @@ -1,24 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile - -# Repackage classes into the top-level. --repackageclasses diff --git a/Jetsurvey/app/src/androidTest/java/com/example/compose/jetsurvey/ExampleInstrumentedTest.kt b/Jetsurvey/app/src/androidTest/java/com/example/compose/jetsurvey/ExampleInstrumentedTest.kt deleted file mode 100644 index 9b7b530365..0000000000 --- a/Jetsurvey/app/src/androidTest/java/com/example/compose/jetsurvey/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.compose.jetsurvey", appContext.packageName) - } -} diff --git a/Jetsurvey/app/src/main/AndroidManifest.xml b/Jetsurvey/app/src/main/AndroidManifest.xml deleted file mode 100644 index 1688a9162e..0000000000 --- a/Jetsurvey/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,40 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.example.compose.jetsurvey"> - - <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" - android:maxSdkVersion="28" /> - - <application - android:allowBackup="true" - android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" - android:supportsRtl="true" - android:theme="@style/Theme.Jetsurvey"> - <activity - android:name=".MainActivity" - android:label="@string/app_name"> - <intent-filter> - <action android:name="android.intent.action.MAIN" /> - - <category android:name="android.intent.category.LAUNCHER" /> - </intent-filter> - </activity> - </application> - -</manifest> diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/MainActivity.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/MainActivity.kt deleted file mode 100644 index d532c6080a..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/MainActivity.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.navigation.findNavController -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.navigation.NavigationView - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - val navView: NavigationView = findViewById(R.id.nav_view) - val navController = findNavController(R.id.nav_host_fragment) - navView.setupWithNavController(navController) - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/Navigation.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/Navigation.kt deleted file mode 100644 index afb0aa601c..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/Navigation.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey - -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import java.security.InvalidParameterException - -enum class Screen { Welcome, SignUp, SignIn, Survey } - -fun Fragment.navigate(to: Screen, from: Screen) { - if (to == from) { - throw InvalidParameterException("Can't navigate to $to") - } - when (to) { - Screen.Welcome -> { - findNavController().navigate(R.id.welcome_fragment) - } - Screen.SignUp -> { - findNavController().navigate(R.id.sign_up_fragment) - } - Screen.SignIn -> { - findNavController().navigate(R.id.sign_in_fragment) - } - Screen.Survey -> { - findNavController().navigate(R.id.survey_fragment) - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/EmailState.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/EmailState.kt deleted file mode 100644 index 0933c77f20..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/EmailState.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import java.util.regex.Pattern - -// Consider an email valid if there's some text before and after a "@" -private const val EMAIL_VALIDATION_REGEX = "^(.+)@(.+)\$" - -class EmailState : - TextFieldState(validator = ::isEmailValid, errorFor = ::emailValidationError) - -/** - * Returns an error to be displayed or null if no error was found - */ -private fun emailValidationError(email: String): String { - return "Invalid email: $email" -} - -private fun isEmailValid(email: String): Boolean { - return Pattern.matches(EMAIL_VALIDATION_REGEX, email) -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/PasswordState.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/PasswordState.kt deleted file mode 100644 index 4b1ffe32db..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/PasswordState.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -class PasswordState : - TextFieldState(validator = ::isPasswordValid, errorFor = ::passwordValidationError) - -class ConfirmPasswordState(private val passwordState: PasswordState) : TextFieldState() { - override val isValid - get() = passwordAndConfirmationValid(passwordState.text, text) - - override fun getError(): String? { - return if (showErrors()) { - passwordConfirmationError() - } else { - null - } - } -} - -private fun passwordAndConfirmationValid(password: String, confirmedPassword: String): Boolean { - return isPasswordValid(password) && password == confirmedPassword -} - -private fun isPasswordValid(password: String): Boolean { - return password.length > 3 -} - -@Suppress("UNUSED_PARAMETER") -private fun passwordValidationError(password: String): String { - return "Invalid password" -} - -private fun passwordConfirmationError(): String { - return "Passwords don't match" -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInFragment.kt deleted file mode 100644 index b943bbb377..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInFragment.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.observe -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.navigate -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -/** - * Fragment containing the sign in UI. - */ -class SignInFragment : Fragment() { - - private val viewModel: SignInViewModel by viewModels { SignInViewModelFactory() } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel.navigateTo.observe(owner = viewLifecycleOwner) { navigateToEvent -> - navigateToEvent.getContentIfNotHandled()?.let { navigateTo -> - navigate(navigateTo, Screen.SignIn) - } - } - - return ComposeView(requireContext()).apply { - // In order for savedState to work, the same ID needs to be used for all instances. - id = R.id.sign_in_fragment - - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - setContent { - JetsurveyTheme { - SignIn( - onNavigationEvent = { event -> - when (event) { - is SignInEvent.SignIn -> { - viewModel.signIn(event.email, event.password) - } - SignInEvent.SignUp -> { - viewModel.signUp() - } - SignInEvent.SignInAsGuest -> { - viewModel.signInAsGuest() - } - SignInEvent.NavigateBack -> { - activity?.onBackPressedDispatcher?.onBackPressed() - } - } - } - ) - } - } - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInScreen.kt deleted file mode 100644 index da4769d303..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInScreen.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.Button -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Snackbar -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme -import com.example.compose.jetsurvey.theme.snackbarAction -import kotlinx.coroutines.launch - -sealed class SignInEvent { - data class SignIn(val email: String, val password: String) : SignInEvent() - object SignUp : SignInEvent() - object SignInAsGuest : SignInEvent() - object NavigateBack : SignInEvent() -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun SignIn(onNavigationEvent: (SignInEvent) -> Unit) { - - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - - val snackbarErrorText = stringResource(id = R.string.feature_not_available) - val snackbarActionLabel = stringResource(id = R.string.dismiss) - - Scaffold( - topBar = { - SignInSignUpTopAppBar( - topAppBarText = stringResource(id = R.string.sign_in), - onBackPressed = { onNavigationEvent(SignInEvent.NavigateBack) } - ) - }, - bodyContent = { - SignInSignUpScreen( - onSignedInAsGuest = { onNavigationEvent(SignInEvent.SignInAsGuest) }, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.fillMaxWidth()) { - SignInContent( - onSignInSubmitted = { email, password -> - onNavigationEvent(SignInEvent.SignIn(email, password)) - } - ) - Spacer(modifier = Modifier.preferredHeight(16.dp)) - TextButton( - onClick = { - scope.launch { - snackbarHostState.showSnackbar( - message = snackbarErrorText, - actionLabel = snackbarActionLabel - ) - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.forgot_password)) - } - } - } - } - ) - - Box(modifier = Modifier.fillMaxSize()) { - ErrorSnackbar( - snackbarHostState = snackbarHostState, - onDismiss = { snackbarHostState.currentSnackbarData?.dismiss() }, - modifier = Modifier.align(Alignment.BottomCenter) - ) - } -} - -@Composable -fun SignInContent( - onSignInSubmitted: (email: String, password: String) -> Unit, -) { - Column(modifier = Modifier.fillMaxWidth()) { - val focusRequester = remember { FocusRequester() } - val emailState = remember { EmailState() } - Email(emailState, onImeAction = { focusRequester.requestFocus() }) - - Spacer(modifier = Modifier.preferredHeight(16.dp)) - - val passwordState = remember { PasswordState() } - Password( - label = stringResource(id = R.string.password), - passwordState = passwordState, - modifier = Modifier.focusRequester(focusRequester), - onImeAction = { onSignInSubmitted(emailState.text, passwordState.text) } - ) - Spacer(modifier = Modifier.preferredHeight(16.dp)) - Button( - onClick = { onSignInSubmitted(emailState.text, passwordState.text) }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - enabled = emailState.isValid && passwordState.isValid - ) { - Text( - text = stringResource(id = R.string.sign_in) - ) - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun ErrorSnackbar( - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, - onDismiss: () -> Unit = { } -) { - SnackbarHost( - hostState = snackbarHostState, - snackbar = { data -> - Snackbar( - modifier = Modifier.padding(16.dp), - text = { - Text( - text = data.message, - style = MaterialTheme.typography.body2 - ) - }, - action = { - data.actionLabel?.let { - TextButton(onClick = onDismiss) { - Text( - text = stringResource(id = R.string.dismiss), - color = MaterialTheme.colors.snackbarAction - ) - } - } - } - ) - }, - modifier = modifier - .fillMaxWidth() - .wrapContentHeight(Alignment.Bottom) - ) -} - -@Preview(name = "Sign in light theme") -@Composable -fun SignInPreview() { - JetsurveyTheme { - SignIn {} - } -} - -@Preview(name = "Sign in dark theme") -@Composable -fun SignInPreviewDark() { - JetsurveyTheme(darkTheme = true) { - SignIn {} - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInSignUp.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInSignUp.kt deleted file mode 100644 index a344145b2b..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInSignUp.kt +++ /dev/null @@ -1,256 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.AmbientTextStyle -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronLeft -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusState -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R - -@Composable -fun SignInSignUpScreen( - onSignedInAsGuest: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable() () -> Unit -) { - ScrollableColumn(modifier = modifier) { - Spacer(modifier = Modifier.preferredHeight(44.dp)) - Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { - content() - } - Spacer(modifier = Modifier.preferredHeight(16.dp)) - OrSignInAsGuest( - onSignedInAsGuest = onSignedInAsGuest, - modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp) - ) - } -} - -@Composable -fun SignInSignUpTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) { - TopAppBar( - title = { - Text( - text = topAppBarText, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center) - ) - }, - navigationIcon = { - IconButton(onClick = onBackPressed) { - Icon(Icons.Filled.ChevronLeft) - } - }, - // We need to balance the navigation icon, so we add a spacer. - actions = { - Spacer(modifier = Modifier.preferredWidth(68.dp)) - }, - backgroundColor = MaterialTheme.colors.surface, - elevation = 0.dp - ) -} - -@Composable -fun Email( - emailState: TextFieldState = remember { EmailState() }, - imeAction: ImeAction = ImeAction.Next, - onImeAction: () -> Unit = {} -) { - OutlinedTextField( - value = emailState.text, - onValueChange = { - emailState.text = it - }, - label = { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.email), - style = MaterialTheme.typography.body2 - ) - } - }, - modifier = Modifier.fillMaxWidth().onFocusChanged { focusState -> - val focused = focusState == FocusState.Active - emailState.onFocusChange(focused) - if (!focused) { - emailState.enableShowErrors() - } - }, - textStyle = MaterialTheme.typography.body2, - isErrorValue = emailState.showErrors(), - keyboardOptions = KeyboardOptions.Default.copy(imeAction = imeAction), - onImeActionPerformed = { action, softKeyboardController -> - if (action == ImeAction.Done) { - softKeyboardController?.hideSoftwareKeyboard() - } - onImeAction() - } - ) - - emailState.getError()?.let { error -> TextFieldError(textError = error) } -} - -@Composable -fun Password( - label: String, - passwordState: TextFieldState, - modifier: Modifier = Modifier, - imeAction: ImeAction = ImeAction.Done, - onImeAction: () -> Unit = {} -) { - val showPassword = remember { mutableStateOf(false) } - OutlinedTextField( - value = passwordState.text, - onValueChange = { - passwordState.text = it - passwordState.enableShowErrors() - }, - modifier = modifier.fillMaxWidth().onFocusChanged { focusState -> - val focused = focusState == FocusState.Active - passwordState.onFocusChange(focused) - if (!focused) { - passwordState.enableShowErrors() - } - }, - textStyle = MaterialTheme.typography.body2, - label = { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = label, - style = MaterialTheme.typography.body2 - ) - } - }, - trailingIcon = { - if (showPassword.value) { - IconButton(onClick = { showPassword.value = false }) { - Icon(imageVector = Icons.Filled.Visibility) - } - } else { - IconButton(onClick = { showPassword.value = true }) { - Icon(imageVector = Icons.Filled.VisibilityOff) - } - } - }, - visualTransformation = if (showPassword.value) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - isErrorValue = passwordState.showErrors(), - keyboardOptions = KeyboardOptions.Default.copy(imeAction = imeAction), - onImeActionPerformed = { action, softKeyboardController -> - if (action == ImeAction.Done) { - softKeyboardController?.hideSoftwareKeyboard() - } - onImeAction() - } - ) - - passwordState.getError()?.let { error -> TextFieldError(textError = error) } -} - -/** - * To be removed when [TextField]s support error - */ -@Composable -fun TextFieldError(textError: String) { - Row(modifier = Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.preferredWidth(16.dp)) - Text( - text = textError, - modifier = Modifier.fillMaxWidth(), - style = AmbientTextStyle.current.copy(color = MaterialTheme.colors.error) - ) - } -} - -@Composable -fun OrSignInAsGuest( - onSignedInAsGuest: () -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Surface { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.or), - style = MaterialTheme.typography.subtitle2 - ) - } - } - OutlinedButton( - onClick = onSignedInAsGuest, - modifier = Modifier.fillMaxWidth().padding(top = 20.dp, bottom = 24.dp) - ) { - Text(text = stringResource(id = R.string.sign_in_guest)) - } - } -} - -@Preview -@Composable -fun SignInSignUpScreenPreview() { - SignInSignUpScreen( - onSignedInAsGuest = {}, - content = {} - ) -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInViewModel.kt deleted file mode 100644 index 76ebde0bef..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignInViewModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.Screen.SignUp -import com.example.compose.jetsurvey.Screen.Survey -import com.example.compose.jetsurvey.util.Event - -class SignInViewModel(private val userRepository: UserRepository) : ViewModel() { - - private val _navigateTo = MutableLiveData<Event<Screen>>() - val navigateTo: LiveData<Event<Screen>> - get() = _navigateTo - - /** - * Consider all sign ins successful - */ - fun signIn(email: String, password: String) { - userRepository.signIn(email, password) - _navigateTo.value = Event(Survey) - } - - fun signInAsGuest() { - userRepository.signInAsGuest() - _navigateTo.value = Event(Survey) - } - - fun signUp() { - _navigateTo.value = Event(SignUp) - } -} - -@Suppress("UNCHECKED_CAST") -class SignInViewModelFactory : ViewModelProvider.Factory { - override fun <T : ViewModel?> create(modelClass: Class<T>): T { - if (modelClass.isAssignableFrom(SignInViewModel::class.java)) { - return SignInViewModel(UserRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpFragment.kt deleted file mode 100644 index c57cf4d7e6..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpFragment.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.observe -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.navigate -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -/** - * Fragment containing the sign up UI - */ -class SignUpFragment : Fragment() { - - private val viewModel: SignUpViewModel by viewModels { SignUpViewModelFactory() } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - viewModel.navigateTo.observe(owner = viewLifecycleOwner) { navigateToEvent -> - navigateToEvent.getContentIfNotHandled()?.let { navigateTo -> - navigate(navigateTo, Screen.SignUp) - } - } - - return ComposeView(requireContext()).apply { - // In order for savedState to work, the same ID needs to be used for all instances. - id = R.id.sign_up_fragment - - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - setContent { - JetsurveyTheme { - SignUp( - onNavigationEvent = { event -> - when (event) { - is SignUpEvent.SignUp -> { - viewModel.signUp(event.email, event.password) - } - SignUpEvent.SignIn -> { - viewModel.signIn() - } - SignUpEvent.SignInAsGuest -> { - viewModel.signInAsGuest() - } - SignUpEvent.NavigateBack -> { - activity?.onBackPressedDispatcher?.onBackPressed() - } - } - } - ) - } - } - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpScreen.kt deleted file mode 100644 index 218333f446..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpScreen.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.Button -import androidx.compose.material.ContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -sealed class SignUpEvent { - object SignIn : SignUpEvent() - data class SignUp(val email: String, val password: String) : SignUpEvent() - object SignInAsGuest : SignUpEvent() - object NavigateBack : SignUpEvent() -} - -@Composable -fun SignUp(onNavigationEvent: (SignUpEvent) -> Unit) { - Scaffold( - topBar = { - SignInSignUpTopAppBar( - topAppBarText = stringResource(id = R.string.create_account), - onBackPressed = { onNavigationEvent(SignUpEvent.NavigateBack) } - ) - }, - bodyContent = { - SignInSignUpScreen( - onSignedInAsGuest = { onNavigationEvent(SignUpEvent.SignInAsGuest) }, - modifier = Modifier.fillMaxWidth() - ) { - Column { - SignUpContent( - onSignUpSubmitted = { email, password -> - onNavigationEvent(SignUpEvent.SignUp(email, password)) - } - ) - } - } - } - ) -} - -@Composable -fun SignUpContent( - onSignUpSubmitted: (email: String, password: String) -> Unit, -) { - Column(modifier = Modifier.fillMaxWidth()) { - val passwordFocusRequest = remember { FocusRequester() } - val confirmationPasswordFocusRequest = remember { FocusRequester() } - val emailState = remember { EmailState() } - Email(emailState, onImeAction = { passwordFocusRequest.requestFocus() }) - - Spacer(modifier = Modifier.preferredHeight(16.dp)) - val passwordState = remember { PasswordState() } - Password( - label = stringResource(id = R.string.password), - passwordState = passwordState, - imeAction = ImeAction.Next, - onImeAction = { confirmationPasswordFocusRequest.requestFocus() }, - modifier = Modifier.focusRequester(passwordFocusRequest) - ) - - Spacer(modifier = Modifier.preferredHeight(16.dp)) - val confirmPasswordState = remember { ConfirmPasswordState(passwordState = passwordState) } - Password( - label = stringResource(id = R.string.confirm_password), - passwordState = confirmPasswordState, - onImeAction = { onSignUpSubmitted(emailState.text, passwordState.text) }, - modifier = Modifier.focusRequester(confirmationPasswordFocusRequest) - ) - - Spacer(modifier = Modifier.preferredHeight(16.dp)) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.terms_and_conditions), - style = MaterialTheme.typography.caption - ) - } - - Spacer(modifier = Modifier.preferredHeight(16.dp)) - Button( - onClick = { onSignUpSubmitted(emailState.text, passwordState.text) }, - modifier = Modifier.fillMaxWidth(), - enabled = emailState.isValid && - passwordState.isValid && confirmPasswordState.isValid - ) { - Text(text = stringResource(id = R.string.create_account)) - } - } -} - -@Preview -@Composable -fun SignUpPreview() { - JetsurveyTheme { - SignUp {} - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpViewModel.kt deleted file mode 100644 index 024941a543..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/SignUpViewModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.Screen.SignIn -import com.example.compose.jetsurvey.Screen.Survey -import com.example.compose.jetsurvey.util.Event - -class SignUpViewModel(private val userRepository: UserRepository) : ViewModel() { - - private val _navigateTo = MutableLiveData<Event<Screen>>() - val navigateTo: LiveData<Event<Screen>> - get() = _navigateTo - - /** - * Consider all sign ups successful - */ - fun signUp(email: String, password: String) { - userRepository.signUp(email, password) - _navigateTo.value = Event(Survey) - } - - fun signInAsGuest() { - userRepository.signInAsGuest() - _navigateTo.value = Event(Survey) - } - - fun signIn() { - _navigateTo.value = Event(SignIn) - } -} - -class SignUpViewModelFactory : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel?> create(modelClass: Class<T>): T { - if (modelClass.isAssignableFrom(SignUpViewModel::class.java)) { - return SignUpViewModel(UserRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/TextFieldState.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/TextFieldState.kt deleted file mode 100644 index 5818b86666..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/TextFieldState.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -open class TextFieldState( - private val validator: (String) -> Boolean = { true }, - private val errorFor: (String) -> String = { "" } -) { - var text: String by mutableStateOf("") - // was the TextField ever focused - var isFocusedDirty: Boolean by mutableStateOf(false) - var isFocused: Boolean by mutableStateOf(false) - private var displayErrors: Boolean by mutableStateOf(false) - - open val isValid: Boolean - get() = validator(text) - - fun onFocusChange(focused: Boolean) { - isFocused = focused - if (focused) isFocusedDirty = true - } - - fun enableShowErrors() { - // only show errors if the text was at least once focused - if (isFocusedDirty) { - displayErrors = true - } - } - - fun showErrors() = !isValid && displayErrors - - open fun getError(): String? { - return if (showErrors()) { - errorFor(text) - } else { - null - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/UserRepository.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/UserRepository.kt deleted file mode 100644 index 7401dc4dd0..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/UserRepository.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.runtime.Immutable - -sealed class User { - @Immutable - data class LoggedInUser(val email: String) : User() - object GuestUser : User() - object NoUserLoggedIn : User() -} - -/** - * Repository that holds the logged in user. - * - * In a production app, this class would also handle the communication with the backend for - * sign in and sign up. - */ -object UserRepository { - - private var _user: User = User.NoUserLoggedIn - val user: User - get() = _user - - @Suppress("UNUSED_PARAMETER") - fun signIn(email: String, password: String) { - _user = User.LoggedInUser(email) - } - - @Suppress("UNUSED_PARAMETER") - fun signUp(email: String, password: String) { - _user = User.LoggedInUser(email) - } - - fun signInAsGuest() { - _user = User.GuestUser - } - - fun isKnownUserEmail(email: String): Boolean { - // if the email contains "sign up" we consider it unknown - return !email.contains("signup") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeFragment.kt deleted file mode 100644 index 56f8b40002..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeFragment.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.observe -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.navigate -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -/** - * Fragment containing the welcome UI. - */ -class WelcomeFragment : Fragment() { - - private val viewModel: WelcomeViewModel by viewModels { WelcomeViewModelFactory() } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel.navigateTo.observe(owner = viewLifecycleOwner) { navigateToEvent -> - navigateToEvent.getContentIfNotHandled()?.let { navigateTo -> - navigate(navigateTo, Screen.Welcome) - } - } - - return ComposeView(requireContext()).apply { - setContent { - JetsurveyTheme { - WelcomeScreen( - onEvent = { event -> - when (event) { - is WelcomeEvent.SignInSignUp -> viewModel.handleContinue( - event.email - ) - WelcomeEvent.SignInAsGuest -> viewModel.signInAsGuest() - } - } - ) - } - } - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeScreen.kt deleted file mode 100644 index 9a05930987..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeScreen.kt +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.compose.animation.core.animateAsState -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.Button -import androidx.compose.material.ContentAlpha -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.boundsInParent -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.AmbientDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -sealed class WelcomeEvent { - data class SignInSignUp(val email: String) : WelcomeEvent() - object SignInAsGuest : WelcomeEvent() -} - -@Composable -fun WelcomeScreen(onEvent: (WelcomeEvent) -> Unit) { - var brandingBottom by remember { mutableStateOf(0f) } - var showBranding by remember { mutableStateOf(true) } - var heightWithBranding by remember { mutableStateOf(0) } - - val currentOffsetHolder = remember { mutableStateOf(0f) } - currentOffsetHolder.value = if (showBranding) 0f else -brandingBottom - val currentOffsetHolderDp = - with(AmbientDensity.current) { currentOffsetHolder.value.toDp() } - val heightDp = with(AmbientDensity.current) { heightWithBranding.toDp() } - Surface(modifier = Modifier.fillMaxSize()) { - val offset by animateAsState(targetValue = currentOffsetHolderDp) - Column( - modifier = Modifier - .fillMaxWidth() - .brandingPreferredHeight(showBranding, heightDp) - .offset(y = offset) - .onSizeChanged { - if (showBranding) { - heightWithBranding = it.height - } - } - ) { - Branding( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .onGloballyPositioned { - if (brandingBottom == 0f) { - brandingBottom = it.boundsInParent.bottom - } - } - ) - SignInCreateAccount( - onEvent = onEvent, - onFocusChange = { focused -> showBranding = !focused }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - ) - } - } -} - -private fun Modifier.brandingPreferredHeight( - showBranding: Boolean, - heightDp: Dp -): Modifier { - return if (!showBranding) { - this - .wrapContentHeight(unbounded = true) - .preferredHeight(heightDp) - } else { - this - } -} - -@Composable -private fun Branding(modifier: Modifier = Modifier) { - Column( - modifier = modifier.wrapContentHeight(align = Alignment.CenterVertically) - ) { - Logo( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(horizontal = 76.dp) - ) - Text( - text = stringResource(id = R.string.app_tagline), - style = MaterialTheme.typography.subtitle1, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(top = 24.dp) - .fillMaxWidth() - ) - } -} - -@Composable -private fun Logo( - modifier: Modifier = Modifier, - lightTheme: Boolean = MaterialTheme.colors.isLight -) { - val assetId = if (lightTheme) { - R.drawable.ic_logo_light - } else { - R.drawable.ic_logo_dark - } - Image( - imageVector = vectorResource(id = assetId), - modifier = modifier - ) -} - -@Composable -private fun SignInCreateAccount( - onEvent: (WelcomeEvent) -> Unit, - onFocusChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - val emailState = remember { EmailState() } - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.sign_in_create_account), - style = MaterialTheme.typography.subtitle2, - textAlign = TextAlign.Center, - modifier = Modifier.padding(vertical = 24.dp) - ) - } - val onSubmit = { - if (emailState.isValid) { - onEvent(WelcomeEvent.SignInSignUp(emailState.text)) - } else { - emailState.enableShowErrors() - } - } - onFocusChange(emailState.isFocused) - Email(emailState = emailState, imeAction = ImeAction.Done, onImeAction = onSubmit) - Button( - onClick = onSubmit, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 28.dp) - ) { - Text( - text = stringResource(id = R.string.user_continue), - style = MaterialTheme.typography.subtitle2 - ) - } - OrSignInAsGuest( - onSignedInAsGuest = { onEvent(WelcomeEvent.SignInAsGuest) }, - modifier = Modifier.fillMaxWidth() - ) - } -} - -@Preview(name = "Welcome light theme") -@Composable -fun WelcomeScreenPreview() { - JetsurveyTheme { - WelcomeScreen {} - } -} - -@Preview(name = "Welcome dark theme") -@Composable -fun WelcomeScreenPreviewDark() { - JetsurveyTheme(darkTheme = true) { - WelcomeScreen {} - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeViewModel.kt deleted file mode 100644 index 073780f771..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/signinsignup/WelcomeViewModel.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.signinsignup - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.compose.jetsurvey.Screen -import com.example.compose.jetsurvey.Screen.SignIn -import com.example.compose.jetsurvey.Screen.SignUp -import com.example.compose.jetsurvey.Screen.Survey -import com.example.compose.jetsurvey.util.Event - -class WelcomeViewModel(private val userRepository: UserRepository) : ViewModel() { - - private val _navigateTo = MutableLiveData<Event<Screen>>() - val navigateTo: LiveData<Event<Screen>> = _navigateTo - - fun handleContinue(email: String) { - if (userRepository.isKnownUserEmail(email)) { - _navigateTo.value = Event(SignIn) - } else { - _navigateTo.value = Event(SignUp) - } - } - - fun signInAsGuest() { - userRepository.signInAsGuest() - _navigateTo.value = Event(Survey) - } -} - -@Suppress("UNCHECKED_CAST") -class WelcomeViewModelFactory : ViewModelProvider.Factory { - override fun <T : ViewModel?> create(modelClass: Class<T>): T { - if (modelClass.isAssignableFrom(WelcomeViewModel::class.java)) { - return WelcomeViewModel(UserRepository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/PhotoUriManager.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/PhotoUriManager.kt deleted file mode 100644 index 8ae71d8205..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/PhotoUriManager.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.content.ContentValues -import android.content.Context -import android.provider.MediaStore - -/** - * Manages the creation of photo Uris. The Uri is used to store the photos taken with camera. - */ -class PhotoUriManager(private val appContext: Context) { - - private val photoCollection by lazy { - MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - } - - private val resolver by lazy { appContext.contentResolver } - - fun buildNewUri() = resolver.insert(photoCollection, buildPhotoDetails()) - - private fun buildPhotoDetails() = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, generateFilename()) - put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") - } - - /** - * Create a unique file name based on the time the photo is taken - */ - private fun generateFilename() = "selfie-${System.currentTimeMillis()}.jpg" -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt deleted file mode 100644 index 8605d1835e..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.net.Uri -import androidx.annotation.StringRes - -data class SurveyResult( - val library: String, - @StringRes val result: Int, - @StringRes val description: Int -) - -data class Survey( - @StringRes val title: Int, - val questions: List<Question> -) - -data class Question( - val id: Int, - @StringRes val questionText: Int, - val answer: PossibleAnswer, - @StringRes val description: Int? = null -) - -/** - * Type of supported actions for a survey - */ -enum class SurveyActionType { PICK_DATE, TAKE_PHOTO, SELECT_CONTACT } - -sealed class SurveyActionResult { - data class Date(val date: String) : SurveyActionResult() - data class Photo(val uri: Uri) : SurveyActionResult() - data class Contact(val contact: String) : SurveyActionResult() -} - -sealed class PossibleAnswer { - data class SingleChoice(val optionsStringRes: List<Int>) : PossibleAnswer() - data class MultipleChoice(val optionsStringRes: List<Int>) : PossibleAnswer() - data class Action( - @StringRes val label: Int, - val actionType: SurveyActionType - ) : PossibleAnswer() - - data class Slider( - val range: ClosedFloatingPointRange<Float>, - val steps: Int, - @StringRes val startText: Int, - @StringRes val endText: Int, - val defaultValue: Float = range.start - ) : PossibleAnswer() -} - -sealed class Answer<T : PossibleAnswer> { - data class SingleChoice(@StringRes val answer: Int) : Answer<PossibleAnswer.SingleChoice>() - data class MultipleChoice(val answersStringRes: Set<Int>) : - Answer<PossibleAnswer.MultipleChoice>() - - data class Action(val result: SurveyActionResult) : Answer<PossibleAnswer.Action>() - data class Slider(val answerValue: Float) : Answer<PossibleAnswer.Slider>() -} - -/** - * Add or remove an answer from the list of selected answers depending on whether the answer was - * selected or deselected. - */ -fun Answer.MultipleChoice.withAnswerSelected( - @StringRes answer: Int, - selected: Boolean -): Answer.MultipleChoice { - val newStringRes = answersStringRes.toMutableSet() - if (!selected) { - newStringRes.remove(answer) - } else { - newStringRes.add(answer) - } - return Answer.MultipleChoice(newStringRes) -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt deleted file mode 100644 index 5a0f56027f..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts.TakePicture -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme -import com.google.android.material.datepicker.MaterialDatePicker - -class SurveyFragment : Fragment() { - - private val viewModel: SurveyViewModel by viewModels { - SurveyViewModelFactory(PhotoUriManager(requireContext().applicationContext)) - } - - private val takePicture = registerForActivityResult(TakePicture()) { photoSaved -> - if (photoSaved) { - viewModel.onImageSaved() - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return ComposeView(requireContext()).apply { - // In order for savedState to work, the same ID needs to be used for all instances. - id = R.id.sign_in_fragment - - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - setContent { - JetsurveyTheme { - viewModel.uiState.observeAsState().value?.let { surveyState -> - when (surveyState) { - is SurveyState.Questions -> SurveyQuestionsScreen( - questions = surveyState, - onAction = { id, action -> handleSurveyAction(id, action) }, - onDonePressed = { viewModel.computeResult(surveyState) }, - onBackPressed = { - activity?.onBackPressedDispatcher?.onBackPressed() - } - ) - is SurveyState.Result -> SurveyResultScreen( - result = surveyState, - onDonePressed = { - activity?.onBackPressedDispatcher?.onBackPressed() - } - ) - } - } - } - } - } - } - - private fun handleSurveyAction(questionId: Int, actionType: SurveyActionType) { - when (actionType) { - SurveyActionType.PICK_DATE -> showDatePicker(questionId) - SurveyActionType.TAKE_PHOTO -> takeAPhoto() - SurveyActionType.SELECT_CONTACT -> selectContact(questionId) - } - } - - private fun showDatePicker(questionId: Int) { - val picker = MaterialDatePicker.Builder.datePicker().build() - activity?.let { - picker.show(it.supportFragmentManager, picker.toString()) - picker.addOnPositiveButtonClickListener { - viewModel.onDatePicked(questionId, picker.headerText) - } - } - } - - private fun takeAPhoto() { - takePicture.launch(viewModel.getUriToSaveImage()) - } - - @Suppress("UNUSED_PARAMETER") - private fun selectContact(questionId: Int) { - // TODO: unsupported for now - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt deleted file mode 100644 index 1538400920..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt +++ /dev/null @@ -1,442 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import androidx.annotation.StringRes -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.selection.selectable -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.Button -import androidx.compose.material.Checkbox -import androidx.compose.material.CheckboxDefaults -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.RadioButton -import androidx.compose.material.RadioButtonDefaults -import androidx.compose.material.Slider -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddAPhoto -import androidx.compose.material.icons.filled.SwapHoriz -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.JetsurveyTheme -import dev.chrisbanes.accompanist.coil.CoilImage - -@Composable -fun Question( - question: Question, - answer: Answer<*>?, - onAnswer: (Answer<*>) -> Unit, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier -) { - ScrollableColumn( - modifier = modifier, - contentPadding = PaddingValues(start = 20.dp, end = 20.dp) - ) { - Spacer(modifier = Modifier.preferredHeight(44.dp)) - val backgroundColor = if (MaterialTheme.colors.isLight) { - MaterialTheme.colors.onSurface.copy(alpha = 0.04f) - } else { - MaterialTheme.colors.onSurface.copy(alpha = 0.06f) - } - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = backgroundColor, - shape = MaterialTheme.shapes.small - ) - ) { - Text( - text = stringResource(id = question.questionText), - style = MaterialTheme.typography.subtitle1, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp, horizontal = 16.dp) - ) - } - Spacer(modifier = Modifier.preferredHeight(24.dp)) - if (question.description != null) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = question.description), - style = MaterialTheme.typography.caption, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp, start = 8.dp, end = 8.dp) - ) - } - } - when (question.answer) { - is PossibleAnswer.SingleChoice -> SingleChoiceQuestion( - possibleAnswer = question.answer, - answer = answer as Answer.SingleChoice?, - onAnswerSelected = { answer -> onAnswer(Answer.SingleChoice(answer)) }, - modifier = Modifier.fillMaxWidth() - ) - is PossibleAnswer.MultipleChoice -> MultipleChoiceQuestion( - possibleAnswer = question.answer, - answer = answer as Answer.MultipleChoice?, - onAnswerSelected = { newAnswer, selected -> - // create the answer if it doesn't exist or - // update it based on the user's selection - if (answer == null) { - onAnswer(Answer.MultipleChoice(setOf(newAnswer))) - } else { - onAnswer(answer.withAnswerSelected(newAnswer, selected)) - } - }, - modifier = Modifier.fillMaxWidth() - ) - is PossibleAnswer.Action -> ActionQuestion( - questionId = question.id, - possibleAnswer = question.answer, - answer = answer as Answer.Action?, - onAction = onAction, - modifier = Modifier.fillMaxWidth() - ) - is PossibleAnswer.Slider -> SliderQuestion( - possibleAnswer = question.answer, - answer = answer as Answer.Slider?, - onAnswerSelected = { onAnswer(Answer.Slider(it)) }, - modifier = Modifier.fillMaxWidth() - ) - } - } -} - -@Composable -private fun SingleChoiceQuestion( - possibleAnswer: PossibleAnswer.SingleChoice, - answer: Answer.SingleChoice?, - onAnswerSelected: (Int) -> Unit, - modifier: Modifier = Modifier -) { - val options = possibleAnswer.optionsStringRes.associateBy { stringResource(id = it) } - - val radioOptions = options.keys.toList() - - val selected = if (answer != null) { - stringResource(id = answer.answer) - } else { - null - } - - val (selectedOption, onOptionSelected) = remember(answer) { mutableStateOf(selected) } - - Column(modifier = modifier) { - radioOptions.forEach { text -> - val onClickHandle = { - onOptionSelected(text) - options[text]?.let { onAnswerSelected(it) } - Unit - } - val optionSelected = text == selectedOption - Surface( - shape = MaterialTheme.shapes.small, - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) - ), - modifier = Modifier.padding(vertical = 8.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = optionSelected, - onClick = onClickHandle - ) - .padding(vertical = 16.dp, horizontal = 24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = text - ) - - RadioButton( - selected = optionSelected, - onClick = onClickHandle, - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colors.primary - ) - ) - } - } - } - } -} - -@Composable -private fun MultipleChoiceQuestion( - possibleAnswer: PossibleAnswer.MultipleChoice, - answer: Answer.MultipleChoice?, - onAnswerSelected: (Int, Boolean) -> Unit, - modifier: Modifier = Modifier -) { - val options = possibleAnswer.optionsStringRes.associateBy { stringResource(id = it) } - Column(modifier = modifier) { - for (option in options) { - var checkedState by remember(answer) { - val selectedOption = answer?.answersStringRes?.contains(option.value) - mutableStateOf(selectedOption ?: false) - } - Surface( - shape = MaterialTheme.shapes.small, - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) - ), - modifier = Modifier.padding(vertical = 4.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - onClick = { - checkedState = !checkedState - onAnswerSelected(option.value, checkedState) - } - ) - .padding(vertical = 16.dp, horizontal = 24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = option.key) - - Checkbox( - checked = checkedState, - onCheckedChange = { selected -> - checkedState = selected - onAnswerSelected(option.value, selected) - }, - colors = CheckboxDefaults.colors( - checkedColor = MaterialTheme.colors.primary - ), - ) - } - } - } - } -} - -@Composable -private fun ActionQuestion( - questionId: Int, - possibleAnswer: PossibleAnswer.Action, - answer: Answer.Action?, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier -) { - when (possibleAnswer.actionType) { - SurveyActionType.PICK_DATE -> { - DateQuestion( - questionId = questionId, - answerLabel = possibleAnswer.label, - answer = answer, - onAction = onAction, - modifier = modifier - ) - } - SurveyActionType.TAKE_PHOTO -> { - PhotoQuestion( - questionId = questionId, - answer = answer, - onAction = onAction, - modifier = modifier - ) - } - SurveyActionType.SELECT_CONTACT -> TODO() - } -} - -@Composable -private fun PhotoQuestion( - questionId: Int, - answer: Answer.Action?, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier -) { - val resource = if (answer != null) { - Icons.Filled.SwapHoriz - } else { - Icons.Filled.AddAPhoto - } - OutlinedButton( - onClick = { onAction(questionId, SurveyActionType.TAKE_PHOTO) }, - modifier = modifier, - contentPadding = PaddingValues() - ) { - Column { - if (answer != null && answer.result is SurveyActionResult.Photo) { - CoilImage( - data = answer.result.uri, - modifier = Modifier.fillMaxSize(), - fadeIn = true - ) - } else { - PhotoDefaultImage(modifier = Modifier.padding(horizontal = 86.dp, vertical = 74.dp)) - } - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentSize(Alignment.BottomCenter) - .padding(vertical = 26.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(resource) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource( - id = if (answer != null) { - R.string.retake_photo - } else { - R.string.add_photo - } - ) - ) - } - } - } -} - -@Composable -private fun DateQuestion( - questionId: Int, - @StringRes answerLabel: Int, - answer: Answer.Action?, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier -) { - Button( - onClick = { onAction(questionId, SurveyActionType.PICK_DATE) }, - modifier = modifier.padding(vertical = 20.dp) - ) { - Text(text = stringResource(id = answerLabel)) - } - - if (answer != null && answer.result is SurveyActionResult.Date) { - Text( - text = stringResource(R.string.selected_date, answer.result.date), - style = MaterialTheme.typography.h4, - modifier = Modifier.padding(vertical = 20.dp) - ) - } -} - -@Composable -private fun PhotoDefaultImage( - modifier: Modifier = Modifier, - lightTheme: Boolean = MaterialTheme.colors.isLight -) { - val assetId = if (lightTheme) { - R.drawable.ic_selfie_light - } else { - R.drawable.ic_selfie_dark - } - Image( - imageVector = vectorResource(id = assetId), - modifier = modifier - ) -} - -@Composable -private fun SliderQuestion( - possibleAnswer: PossibleAnswer.Slider, - answer: Answer.Slider?, - onAnswerSelected: (Float) -> Unit, - modifier: Modifier = Modifier -) { - var sliderPosition by remember { - mutableStateOf(answer?.answerValue ?: possibleAnswer.defaultValue) - } - Row(modifier = modifier) { - Text( - text = stringResource(id = possibleAnswer.startText), - modifier = Modifier.align(Alignment.CenterVertically) - ) - Slider( - value = sliderPosition, - onValueChange = { - sliderPosition = it - onAnswerSelected(it) - }, - valueRange = possibleAnswer.range, - steps = possibleAnswer.steps, - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp) - ) - Text( - text = stringResource(id = possibleAnswer.endText), - modifier = Modifier.align(Alignment.CenterVertically) - ) - } -} - -@Preview -@Composable -fun QuestionPreview() { - val question = Question( - id = 2, - questionText = R.string.pick_superhero, - answer = PossibleAnswer.SingleChoice( - optionsStringRes = listOf( - R.string.spiderman, - R.string.ironman, - R.string.unikitty, - R.string.captain_planet - ) - ), - description = R.string.select_one - ) - JetsurveyTheme { - Question(question = question, answer = null, onAnswer = {}, onAction = { _, _ -> }) - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt deleted file mode 100644 index c5e314866b..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.os.Build -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.survey.PossibleAnswer.Action -import com.example.compose.jetsurvey.survey.PossibleAnswer.MultipleChoice -import com.example.compose.jetsurvey.survey.PossibleAnswer.SingleChoice -import com.example.compose.jetsurvey.survey.SurveyActionType.PICK_DATE -import com.example.compose.jetsurvey.survey.SurveyActionType.TAKE_PHOTO - -// Static data of questions -private val jetpackQuestions = mutableListOf( - Question( - id = 1, - questionText = R.string.in_my_free_time, - answer = MultipleChoice( - optionsStringRes = listOf( - R.string.read, - R.string.work_out, - R.string.draw, - R.string.play_games, - R.string.dance, - R.string.watch_movies - ) - ), - description = R.string.select_all - ), - Question( - id = 2, - questionText = R.string.pick_superhero, - answer = SingleChoice( - optionsStringRes = listOf( - R.string.spiderman, - R.string.ironman, - R.string.unikitty, - R.string.captain_planet - ) - ), - description = R.string.select_one - ), - Question( - id = 7, - questionText = R.string.favourite_movie, - answer = SingleChoice( - listOf( - R.string.star_trek, - R.string.social_network, - R.string.back_to_future, - R.string.outbreak - ) - ), - description = R.string.select_one - ), - Question( - id = 3, - questionText = R.string.takeaway, - answer = Action(label = R.string.pick_date, actionType = PICK_DATE), - description = R.string.select_date - ), - Question( - id = 4, - questionText = R.string.selfies, - answer = PossibleAnswer.Slider( - range = 1f..10f, - steps = 3, - startText = R.string.selfie_min, - endText = R.string.selfie_max - ) - ) -).apply { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { - // Add the camera feature only for devices 29+ - add( - Question( - id = 975, - questionText = R.string.selfie_skills, - answer = Action(label = R.string.add_photo, actionType = TAKE_PHOTO) - ) - ) - } -}.toList() - -private val jetpackSurvey = Survey( - title = R.string.which_jetpack_library, - questions = jetpackQuestions -) - -object SurveyRepository { - - suspend fun getSurvey() = jetpackSurvey - - @Suppress("UNUSED_PARAMETER") - fun getSurveyResult(answers: List<Answer<*>>): SurveyResult { - return SurveyResult( - library = "Compose", - result = R.string.survey_result, - description = R.string.survey_result_description - ) - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt deleted file mode 100644 index f6a186fa7a..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.layout.ConstraintLayout -import androidx.compose.foundation.layout.ExperimentalLayout -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.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.width -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.Button -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.theme.progressIndicatorBackground - -@Composable -fun SurveyQuestionsScreen( - questions: SurveyState.Questions, - onAction: (Int, SurveyActionType) -> Unit, - onDonePressed: () -> Unit, - onBackPressed: () -> Unit -) { - val questionState = remember(questions.currentQuestionIndex) { - questions.questionsState[questions.currentQuestionIndex] - } - - Surface(modifier = Modifier.fillMaxSize()) { - Scaffold( - topBar = { - SurveyTopAppBar( - questionIndex = questionState.questionIndex, - totalQuestionsCount = questionState.totalQuestionsCount, - onBackPressed = onBackPressed - ) - }, - bodyContent = { innerPadding -> - Question( - question = questionState.question, - answer = questionState.answer, - onAnswer = { - questionState.answer = it - questionState.enableNext = true - }, - onAction = onAction, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) - }, - bottomBar = { - SurveyBottomBar( - questionState = questionState, - onPreviousPressed = { questions.currentQuestionIndex-- }, - onNextPressed = { questions.currentQuestionIndex++ }, - onDonePressed = onDonePressed - ) - } - ) - } -} - -@Composable -fun SurveyResultScreen( - result: SurveyState.Result, - onDonePressed: () -> Unit -) { - Surface(modifier = Modifier.fillMaxSize()) { - Scaffold( - bodyContent = { innerPadding -> - val modifier = Modifier.padding(innerPadding) - SurveyResult(result = result, modifier = modifier) - }, - bottomBar = { - OutlinedButton( - onClick = { onDonePressed() }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 24.dp) - ) { - Text(text = stringResource(id = R.string.done)) - } - } - ) - } -} - -@Composable -private fun SurveyResult(result: SurveyState.Result, modifier: Modifier = Modifier) { - ScrollableColumn(modifier = modifier.fillMaxSize()) { - Spacer(modifier = Modifier.preferredHeight(44.dp)) - Text( - text = result.surveyResult.library, - style = MaterialTheme.typography.h3, - modifier = Modifier.padding(horizontal = 20.dp) - ) - Text( - text = stringResource( - result.surveyResult.result, - result.surveyResult.library - ), - style = MaterialTheme.typography.subtitle1, - modifier = Modifier.padding(20.dp) - ) - Text( - text = stringResource(result.surveyResult.description), - style = MaterialTheme.typography.body1, - modifier = Modifier.padding(horizontal = 20.dp) - ) - } -} - -@Composable -private fun TopAppBarTitle( - questionIndex: Int, - totalQuestionsCount: Int, - modifier: Modifier = Modifier -) { - val indexStyle = MaterialTheme.typography.caption.toSpanStyle().copy( - fontWeight = FontWeight.Bold - ) - val totalStyle = MaterialTheme.typography.caption.toSpanStyle() - val text = buildAnnotatedString { - withStyle(style = indexStyle) { - append("${questionIndex + 1}") - } - withStyle(style = totalStyle) { - append(stringResource(R.string.question_count, totalQuestionsCount)) - } - } - Text( - text = text, - style = MaterialTheme.typography.caption, - modifier = modifier - ) -} - -@OptIn(ExperimentalLayout::class) -@Composable -private fun SurveyTopAppBar( - questionIndex: Int, - totalQuestionsCount: Int, - onBackPressed: () -> Unit -) { - ConstraintLayout(modifier = Modifier.fillMaxWidth()) { - val (button, text, progress) = createRefs() - TopAppBarTitle( - questionIndex = questionIndex, - totalQuestionsCount = totalQuestionsCount, - modifier = Modifier - .padding(vertical = 20.dp) - .constrainAs(text) { centerHorizontallyTo(parent) } - ) - - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - IconButton( - onClick = onBackPressed, - modifier = Modifier - .padding(horizontal = 12.dp) - .constrainAs(button) { end.linkTo(parent.end) } - ) { - Icon(Icons.Filled.Close) - } - } - - LinearProgressIndicator( - progress = (questionIndex + 1) / totalQuestionsCount.toFloat(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .constrainAs(progress) { - bottom.linkTo(text.bottom) - }, - backgroundColor = MaterialTheme.colors.progressIndicatorBackground - ) - } -} - -@Composable -private fun SurveyBottomBar( - questionState: QuestionState, - onPreviousPressed: () -> Unit, - onNextPressed: () -> Unit, - onDonePressed: () -> Unit -) { - Surface( - elevation = 3.dp, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 20.dp) - ) { - if (questionState.showPrevious) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = onPreviousPressed - ) { - Text(text = stringResource(id = R.string.previous)) - } - Spacer(modifier = Modifier.width(16.dp)) - } - if (questionState.showDone) { - Button( - modifier = Modifier.weight(1f), - onClick = onDonePressed, - enabled = questionState.enableNext - ) { - Text(text = stringResource(id = R.string.done)) - } - } else { - Button( - modifier = Modifier.weight(1f), - onClick = onNextPressed, - enabled = questionState.enableNext - ) { - Text(text = stringResource(id = R.string.next)) - } - } - } - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt deleted file mode 100644 index b9b92ba40a..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import androidx.annotation.StringRes -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -@Stable -class QuestionState( - val question: Question, - val questionIndex: Int, - val totalQuestionsCount: Int, - val showPrevious: Boolean, - val showDone: Boolean -) { - var enableNext by mutableStateOf(false) - var answer by mutableStateOf<Answer<*>?>(null) -} - -sealed class SurveyState { - data class Questions( - @StringRes val surveyTitle: Int, - val questionsState: List<QuestionState> - ) : SurveyState() { - var currentQuestionIndex by mutableStateOf(0) - } - - data class Result( - @StringRes val surveyTitle: Int, - val surveyResult: SurveyResult - ) : SurveyState() -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt deleted file mode 100644 index f5e86aa747..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.net.Uri -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch - -class SurveyViewModel( - private val surveyRepository: SurveyRepository, - private val photoUriManager: PhotoUriManager -) : ViewModel() { - - private val _uiState = MutableLiveData<SurveyState>() - val uiState: LiveData<SurveyState> - get() = _uiState - - private lateinit var surveyInitialState: SurveyState - - // Uri used to save photos taken with the camera - private var uri: Uri? = null - - init { - viewModelScope.launch { - val survey = surveyRepository.getSurvey() - - // Create the default questions state based on the survey questions - val questions: List<QuestionState> = survey.questions.mapIndexed { index, question -> - val showPrevious = index > 0 - val showDone = index == survey.questions.size - 1 - QuestionState( - question = question, - questionIndex = index, - totalQuestionsCount = survey.questions.size, - showPrevious = showPrevious, - showDone = showDone - ) - } - surveyInitialState = SurveyState.Questions(survey.title, questions) - _uiState.value = surveyInitialState - } - } - - fun computeResult(surveyQuestions: SurveyState.Questions) { - val answers = surveyQuestions.questionsState.mapNotNull { it.answer } - val result = surveyRepository.getSurveyResult(answers) - _uiState.value = SurveyState.Result(surveyQuestions.surveyTitle, result) - } - - fun onDatePicked(questionId: Int, date: String) { - updateStateWithActionResult(questionId, SurveyActionResult.Date(date)) - } - - fun getUriToSaveImage(): Uri? { - uri = photoUriManager.buildNewUri() - return uri - } - - fun onImageSaved() { - uri?.let { uri -> - getLatestQuestionId()?.let { questionId -> - updateStateWithActionResult(questionId, SurveyActionResult.Photo(uri)) - } - } - } - - private fun updateStateWithActionResult(questionId: Int, result: SurveyActionResult) { - val latestState = _uiState.value - if (latestState != null && latestState is SurveyState.Questions) { - val question = - latestState.questionsState.first { questionState -> - questionState.question.id == questionId - } - question.answer = Answer.Action(result) - question.enableNext = true - } - } - - private fun getLatestQuestionId(): Int? { - val latestState = _uiState.value - if (latestState != null && latestState is SurveyState.Questions) { - return latestState.questionsState[latestState.currentQuestionIndex].question.id - } - return null - } -} - -class SurveyViewModelFactory( - private val photoUriManager: PhotoUriManager -) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel?> create(modelClass: Class<T>): T { - if (modelClass.isAssignableFrom(SurveyViewModel::class.java)) { - return SurveyViewModel(SurveyRepository, photoUriManager) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Color.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Color.kt deleted file mode 100644 index 5efe51c9c8..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Color.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.theme - -import androidx.compose.ui.graphics.Color - -val Purple300 = Color(0xFFCD52FC) -val Purple600 = Color(0xFF9F00F4) -val Purple700 = Color(0xFF8100EF) -val Purple800 = Color(0xFF0000E1) - -val Red300 = Color(0xFFD00036) -val Red800 = Color(0xFFEA6D7E) - -val Gray100 = Color(0xFFF5F5F5) -val Gray900 = Color(0xFF212121) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Shape.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Shape.kt deleted file mode 100644 index 62ee6482d6..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Shape.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -val Shapes = Shapes( - small = RoundedCornerShape(12.dp) -) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Theme.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Theme.kt deleted file mode 100644 index 2546ef3747..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Theme.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.theme - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -val LightThemeColors = lightColors( - primary = Purple700, - primaryVariant = Purple800, - onPrimary = Color.White, - secondary = Color.White, - onSecondary = Color.Black, - background = Color.White, - onBackground = Color.Black, - surface = Color.White, - onSurface = Color.Black, - error = Red800, - onError = Color.White -) - -val DarkThemeColors = darkColors( - primary = Purple300, - primaryVariant = Purple600, - onPrimary = Color.Black, - secondary = Color.Black, - onSecondary = Color.White, - background = Color.Black, - onBackground = Color.White, - surface = Color.Black, - onSurface = Color.White, - error = Red300, - onError = Color.Black -) - -val Colors.snackbarAction: Color - @Composable - get() = if (isLight) Purple300 else Purple700 - -val Colors.progressIndicatorBackground: Color - @Composable - get() = if (isLight) Color.Black.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.24f) - -@Composable -fun JetsurveyTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { - val colors = if (darkTheme) { - DarkThemeColors - } else { - LightThemeColors - } - MaterialTheme( - colors = colors, - typography = Typography, - shapes = Shapes, - content = content - ) -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Typography.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Typography.kt deleted file mode 100644 index fb30a47165..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/theme/Typography.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.theme - -import androidx.compose.material.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.font.font -import androidx.compose.ui.text.font.fontFamily -import androidx.compose.ui.unit.sp -import com.example.compose.jetsurvey.R - -val MontserratFontFamily = fontFamily( - font(R.font.montserrat_regular), - font(R.font.montserrat_medium, FontWeight.Medium), - font(R.font.montserrat_semibold, FontWeight.SemiBold) -) - -val Typography = Typography( - defaultFontFamily = MontserratFontFamily, - h1 = TextStyle( - fontWeight = FontWeight.W300, - fontSize = 96.sp, - letterSpacing = (-1.5).sp - ), - h2 = TextStyle( - fontWeight = FontWeight.W300, - fontSize = 60.sp, - letterSpacing = (-0.5).sp - ), - h3 = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 48.sp, - letterSpacing = 0.sp - ), - h4 = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 30.sp, - letterSpacing = 0.sp - ), - h5 = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 24.sp, - letterSpacing = 0.sp - ), - h6 = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 20.sp, - letterSpacing = 0.sp - ), - subtitle1 = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 16.sp, - letterSpacing = 0.15.sp - ), - subtitle2 = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 14.sp, - letterSpacing = 0.1.sp - ), - body1 = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 16.sp, - letterSpacing = 0.5.sp - ), - body2 = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 14.sp, - letterSpacing = 0.25.sp - ), - button = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 14.sp, - letterSpacing = 0.25.sp - ), - caption = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 12.sp, - letterSpacing = 0.4.sp - ), - overline = TextStyle( - fontWeight = FontWeight.W600, - fontSize = 12.sp, - letterSpacing = 1.sp - ) -) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/util/Event.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/util/Event.kt deleted file mode 100644 index ebf6dc0164..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/util/Event.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.util - -/** - * Used as a wrapper for data that is exposed via a LiveData that represents an event. - */ -data class Event<out T>(private val content: T) { - - var hasBeenHandled = false - private set // Allow external read but not write - - /** - * Returns the content and prevents its use again. - */ - fun getContentIfNotHandled(): T? { - return if (hasBeenHandled) { - null - } else { - hasBeenHandled = true - content - } - } - - /** - * Returns the content, even if it's already been handled. - */ - fun peekContent(): T = content -} diff --git a/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_background.xml b/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_background.xml deleted file mode 100644 index 05d4c0bbf7..0000000000 --- a/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_background.xml +++ /dev/null @@ -1,28 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="108dp" - android:height="108dp" - android:viewportWidth="108" - android:viewportHeight="108"> - <path - android:pathData="M0,0h108v108H0z" - android:fillColor="#FFF" - android:fillType="evenOdd"/> - <path - android:pathData="M68,57.07L52.572,70 46,61.954v-9.77l7.714,9.483L68,49z" - android:fillColor="#000" - android:fillType="evenOdd"/> -</vector> diff --git a/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_foreground.xml deleted file mode 100644 index 86f401cca4..0000000000 --- a/Jetsurvey/app/src/main/res/drawable-v26/ic_launcher_foreground.xml +++ /dev/null @@ -1,24 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="108dp" - android:height="108dp" - android:viewportWidth="108" - android:viewportHeight="108"> - <path - android:pathData="M54.165,55.764l13.833,-11.27L68,36 55.013,47.294l-2.725,-2.418 -0.799,-0.62a11.186,11.186 0,0 0,-2.551 -1.49l-0.262,-0.11a7.752,7.752 0,0 0,-1.17 -0.383l-0.234,-0.056a6.418,6.418 0,0 0,-3.884 0.28l-0.698,0.28 -0.218,0.109a8.545,8.545 0,0 0,-1.69 1.113l-0.118,0.1A7.565,7.565 0,0 0,39.11 45.9l-0.06,0.097a7.04,7.04 0,0 0,-0.84 5.403l0.078,0.312a8.745,8.745 0,0 0,1.583 3.251l0.842,1.083L43.818,60V49.862a1.94,1.94 0,0 1,1.783 -1.936c0.484,-0.043 0.974,0.094 1.365,0.386l0.317,0.24c0.138,0.102 0.264,0.216 0.384,0.34l6.498,6.872z" - android:fillColor="#8100EF" - android:fillType="nonZero"/> -</vector> diff --git a/Jetsurvey/app/src/main/res/drawable/ic_logo_dark.xml b/Jetsurvey/app/src/main/res/drawable/ic_logo_dark.xml deleted file mode 100644 index d81c9ccf49..0000000000 --- a/Jetsurvey/app/src/main/res/drawable/ic_logo_dark.xml +++ /dev/null @@ -1,57 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="219dp" - android:height="123dp" - android:viewportWidth="219" - android:viewportHeight="123"> - <path - android:pathData="M131.363,31.469L109.202,49.94L99.762,38.447V24.491L110.844,38.037L131.363,19.943V31.469Z" - android:fillColor="#ffffff" - android:fillType="evenOdd"/> - <path - android:pathData="M111.254,29.417L131.363,13.032L131.367,0.685L112.486,17.103L108.526,13.587L107.365,12.686C106.229,11.804 104.982,11.075 103.656,10.52L103.273,10.36C102.722,10.129 102.153,9.943 101.573,9.804L101.232,9.722C100.086,9.446 98.898,9.39 97.731,9.557C96.995,9.662 96.275,9.854 95.586,10.13L94.572,10.536L94.254,10.694C93.374,11.134 92.55,11.677 91.798,12.312L91.627,12.457C90.74,13.206 89.978,14.09 89.368,15.078L89.281,15.219C88.513,16.461 88.021,17.852 87.836,19.3C87.675,20.561 87.751,21.84 88.059,23.073L88.172,23.527C88.602,25.246 89.385,26.855 90.473,28.253L91.698,29.827L96.213,35.574V20.836C96.213,20.275 96.38,19.728 96.692,19.263C97.168,18.553 97.952,18.096 98.803,18.021C99.508,17.959 100.221,18.159 100.787,18.583L101.251,18.931C101.449,19.08 101.635,19.246 101.806,19.427L111.254,29.417Z" - android:fillColor="#CD52FC"/> - <path - android:pathData="M11.568,116.87C12.468,115.25 12.918,113.45 12.918,111.379V89.144C12.918,88.469 12.693,87.928 12.288,87.478C11.838,87.073 11.298,86.848 10.623,86.848C9.947,86.848 9.407,87.073 8.957,87.478C8.507,87.928 8.327,88.469 8.327,89.144V111.379C8.327,113.179 7.742,114.62 6.617,115.745C5.446,116.87 4.006,117.41 2.251,117.41C1.575,117.41 1.035,117.635 0.63,118.086C0.18,118.536 0,119.076 0,119.706C0,120.381 0.18,120.921 0.63,121.371C1.035,121.821 1.575,122.002 2.251,122.002C4.321,122.002 6.166,121.551 7.787,120.651C9.407,119.751 10.667,118.491 11.568,116.87Z" - android:fillColor="#ffffff"/> - <path - android:pathData="M41.817,98.776C41.817,99.361 41.591,99.856 41.231,100.216C40.826,100.576 40.331,100.757 39.746,100.757H22.642C22.957,102.872 23.902,104.582 25.478,105.843C27.053,107.148 28.989,107.778 31.284,107.778C32.184,107.778 33.084,107.643 34.075,107.283C35.065,106.968 35.875,106.563 36.505,106.068C36.91,105.753 37.405,105.573 37.991,105.573C38.531,105.573 38.981,105.753 39.341,106.023C39.881,106.473 40.151,107.013 40.151,107.553C40.151,108.093 39.881,108.543 39.431,108.903C38.396,109.714 37.135,110.389 35.605,110.884C34.075,111.424 32.634,111.649 31.284,111.649C28.809,111.649 26.603,111.154 24.667,110.074C22.687,108.993 21.157,107.508 20.076,105.618C18.996,103.727 18.456,101.612 18.456,99.181C18.456,96.796 18.951,94.635 19.986,92.745C21.022,90.854 22.462,89.369 24.307,88.289C26.153,87.208 28.268,86.668 30.609,86.668C32.904,86.668 34.885,87.208 36.595,88.199C38.261,89.234 39.566,90.674 40.466,92.475C41.366,94.32 41.817,96.436 41.817,98.776ZM30.609,90.539C28.403,90.539 26.603,91.17 25.253,92.34C23.902,93.555 23.047,95.176 22.687,97.156H37.675C37.405,95.176 36.64,93.555 35.425,92.34C34.165,91.17 32.589,90.539 30.609,90.539Z" - android:fillColor="#ffffff" - android:fillType="evenOdd"/> - <path - android:pathData="M48.708,87.748V102.737C48.708,104.402 49.023,105.888 49.743,107.193C50.418,108.543 51.364,109.578 52.579,110.344C53.794,111.109 55.145,111.469 56.63,111.469H57.44C58.205,111.469 58.835,111.289 59.331,110.839C59.826,110.434 60.096,109.894 60.096,109.218C60.096,108.588 59.871,108.048 59.511,107.598C59.151,107.193 58.7,106.968 58.16,106.968H56.63C55.64,106.968 54.829,106.563 54.199,105.753C53.524,104.942 53.209,103.952 53.209,102.737V91.619H57.035C57.665,91.619 58.16,91.439 58.565,91.079C58.925,90.719 59.151,90.269 59.151,89.729C59.151,89.144 58.925,88.649 58.565,88.288C58.16,87.928 57.665,87.748 57.035,87.748H53.209V81.267C53.209,80.637 52.984,80.097 52.579,79.646C52.129,79.241 51.589,79.016 50.958,79.016C50.283,79.016 49.743,79.241 49.338,79.646C48.888,80.097 48.708,80.637 48.708,81.267L48.708,87.748Z" - android:fillColor="#ffffff"/> - <path - android:pathData="M203.209,122.127C202.909,122.127 202.55,122.037 202.13,121.857C200.811,121.257 200.451,120.313 201.051,119.024L205.547,108.906L195.43,89.704C195.13,89.135 195.085,88.595 195.295,88.086C195.534,87.546 195.939,87.156 196.509,86.916C197.078,86.677 197.618,86.662 198.128,86.871C198.637,87.051 199.027,87.426 199.297,87.996L207.526,104.364L214.766,88.041C215.366,86.782 216.31,86.422 217.599,86.961C218.948,87.531 219.323,88.475 218.723,89.794L204.963,120.733C204.603,121.662 204.019,122.127 203.209,122.127Z" - android:fillColor="#ffffff"/> - <path - android:pathData="M182.429,111.514C179.941,111.514 177.723,110.99 175.774,109.94C173.855,108.861 172.341,107.392 171.232,105.533C170.153,103.645 169.613,101.486 169.613,99.058C169.613,96.6 170.123,94.441 171.142,92.582C172.191,90.694 173.63,89.225 175.459,88.176C177.288,87.096 179.386,86.557 181.755,86.557C184.093,86.557 186.102,87.081 187.781,88.131C189.459,89.15 190.733,90.574 191.603,92.403C192.502,94.201 192.952,96.285 192.952,98.653C192.952,99.223 192.757,99.702 192.367,100.092C191.978,100.452 191.483,100.632 190.883,100.632H173.795C174.125,102.7 175.069,104.394 176.628,105.713C178.217,107.002 180.151,107.647 182.429,107.647C183.359,107.647 184.303,107.482 185.262,107.152C186.252,106.793 187.046,106.388 187.646,105.938C188.095,105.608 188.575,105.444 189.085,105.444C189.624,105.414 190.089,105.563 190.479,105.893C190.988,106.343 191.258,106.838 191.288,107.377C191.318,107.917 191.078,108.381 190.569,108.771C189.549,109.581 188.275,110.24 186.746,110.75C185.247,111.259 183.808,111.514 182.429,111.514ZM181.755,90.424C179.536,90.424 177.753,91.039 176.404,92.268C175.054,93.497 174.2,95.086 173.84,97.034H188.815C188.545,95.116 187.811,93.542 186.611,92.313C185.412,91.054 183.793,90.424 181.755,90.424Z" - android:fillColor="#ffffff"/> - <path - android:pathData="M155.433,111.29C154.504,111.29 153.785,110.795 153.275,109.806L143.742,89.705C143.502,89.195 143.472,88.701 143.652,88.221C143.862,87.711 144.251,87.321 144.821,87.052C145.331,86.782 145.84,86.737 146.35,86.917C146.889,87.097 147.294,87.441 147.564,87.951L155.389,104.814L163.123,87.951C163.393,87.441 163.798,87.097 164.337,86.917C164.907,86.737 165.476,86.782 166.046,87.052C166.586,87.291 166.945,87.666 167.125,88.176C167.305,88.686 167.275,89.195 167.035,89.705L157.502,109.806C157.052,110.795 156.363,111.29 155.433,111.29Z" - android:fillColor="#ffffff"/> - <path - android:pathData="M124.579,111.335C123.081,111.335 122.331,110.585 122.331,109.086V88.985C122.331,87.486 123.081,86.737 124.579,86.737C126.078,86.737 125.465,86.737 126.459,86.737L127.291,86.737C128.34,86.737 128.749,86.737 129.998,86.737C131.247,86.737 132.841,86.737 134.37,86.737C136.168,86.737 137.515,86.767 138.385,87.366C139.284,87.936 139.629,88.626 139.419,89.435C139.269,90.064 138.969,90.484 138.52,90.694C138.07,90.874 137.545,90.904 136.946,90.784C135.027,90.394 133.303,90.364 131.774,90.694C130.246,91.024 129.031,91.638 128.132,92.538C127.263,93.437 126.828,94.576 126.828,95.955V109.086C126.828,110.585 126.078,111.335 124.579,111.335Z" - android:fillColor="#ffffff"/> - <path - android:pathData="M76.21,111.514C74.112,111.514 72.163,111.199 70.364,110.57C68.595,109.91 67.216,109.086 66.227,108.097C65.777,107.617 65.582,107.077 65.642,106.478C65.732,105.848 66.032,105.339 66.542,104.949C67.141,104.469 67.726,104.274 68.296,104.364C68.895,104.424 69.405,104.679 69.825,105.129C70.334,105.698 71.144,106.238 72.253,106.748C73.392,107.227 74.651,107.467 76.03,107.467C77.769,107.467 79.088,107.182 79.987,106.613C80.917,106.043 81.396,105.309 81.426,104.409C81.456,103.51 81.022,102.73 80.122,102.071C79.253,101.411 77.649,100.872 75.311,100.452C72.283,99.852 70.079,98.953 68.7,97.754C67.351,96.555 66.677,95.086 66.677,93.347C66.677,91.818 67.126,90.559 68.026,89.57C68.925,88.55 70.079,87.801 71.488,87.321C72.897,86.812 74.366,86.557 75.895,86.557C77.874,86.557 79.628,86.871 81.157,87.501C82.686,88.131 83.9,89 84.799,90.109C85.219,90.589 85.414,91.099 85.384,91.638C85.354,92.148 85.099,92.582 84.619,92.942C84.14,93.272 83.57,93.377 82.91,93.257C82.251,93.137 81.696,92.867 81.246,92.448C80.497,91.728 79.688,91.233 78.818,90.964C77.949,90.694 76.945,90.559 75.805,90.559C74.486,90.559 73.362,90.784 72.433,91.233C71.533,91.683 71.084,92.343 71.084,93.212C71.084,93.752 71.218,94.246 71.488,94.696C71.788,95.116 72.358,95.506 73.197,95.865C74.036,96.195 75.266,96.525 76.884,96.854C80.272,97.514 82.61,98.458 83.9,99.688C85.219,100.887 85.878,102.371 85.878,104.139C85.878,105.518 85.504,106.763 84.754,107.872C84.035,108.981 82.955,109.865 81.516,110.525C80.107,111.184 78.339,111.514 76.21,111.514Z" - android:fillColor="#ffffff"/> - <path - android:pathData="M92.461,106.348C93.406,108.059 94.712,109.409 96.332,110.309C97.907,111.119 99.618,111.57 101.508,111.615C101.553,111.615 101.643,111.66 101.643,111.66H111.996C112.626,111.66 113.121,111.435 113.526,111.029C113.976,110.579 114.156,110.084 114.156,109.499V109.319V109.139V89.064C114.156,88.389 113.976,87.849 113.526,87.399C113.076,86.949 112.536,86.769 111.861,86.769C111.186,86.769 110.645,86.949 110.195,87.399C109.79,87.849 109.565,88.389 109.565,89.064V101.757C109.565,102.838 109.295,103.783 108.71,104.683C108.125,105.538 107.315,106.213 106.279,106.753C105.244,107.249 104.074,107.519 102.814,107.519C100.653,107.519 98.943,106.888 97.637,105.628C96.332,104.368 95.657,102.522 95.657,100.137V89.064C95.657,88.389 95.477,87.849 95.027,87.399C94.577,86.949 94.036,86.769 93.361,86.769C92.731,86.769 92.191,86.949 91.741,87.399C91.291,87.849 91.066,88.389 91.066,89.064V100.137C91.066,102.567 91.561,104.638 92.461,106.348Z" - android:fillColor="#ffffff"/> -</vector> diff --git a/Jetsurvey/app/src/main/res/drawable/ic_logo_light.xml b/Jetsurvey/app/src/main/res/drawable/ic_logo_light.xml deleted file mode 100644 index 826f755de6..0000000000 --- a/Jetsurvey/app/src/main/res/drawable/ic_logo_light.xml +++ /dev/null @@ -1,57 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="219dp" - android:height="123dp" - android:viewportWidth="219" - android:viewportHeight="123"> - <path - android:pathData="M131.363,31.469L109.202,49.94L99.761,38.447V24.491L110.843,38.037L131.363,19.943V31.469Z" - android:fillColor="#000000" - android:fillType="evenOdd"/> - <path - android:pathData="M111.254,29.417L131.362,13.032L131.366,0.685L112.485,17.103L108.526,13.587L107.364,12.686C106.229,11.804 104.981,11.075 103.655,10.52L103.272,10.36C102.721,10.129 102.153,9.943 101.572,9.804L101.231,9.722C100.085,9.446 98.897,9.39 97.73,9.557C96.995,9.662 96.274,9.854 95.585,10.13L94.571,10.536L94.254,10.694C93.374,11.134 92.549,11.677 91.798,12.312L91.626,12.457C90.74,13.206 89.977,14.09 89.367,15.078L89.28,15.219C88.513,16.461 88.02,17.852 87.835,19.3C87.675,20.561 87.75,21.84 88.058,23.073L88.172,23.527C88.601,25.246 89.385,26.855 90.473,28.253L91.698,29.827L96.213,35.574V20.836C96.213,20.275 96.379,19.728 96.691,19.263C97.167,18.553 97.951,18.096 98.802,18.021C99.507,17.959 100.22,18.159 100.786,18.583L101.25,18.931C101.449,19.08 101.634,19.246 101.805,19.427L111.254,29.417Z" - android:fillColor="#8100EF"/> - <path - android:pathData="M11.568,116.87C12.468,115.25 12.918,113.45 12.918,111.379V89.144C12.918,88.469 12.693,87.928 12.288,87.478C11.838,87.073 11.298,86.848 10.623,86.848C9.947,86.848 9.407,87.073 8.957,87.478C8.507,87.928 8.327,88.469 8.327,89.144V111.379C8.327,113.179 7.742,114.62 6.617,115.745C5.446,116.87 4.006,117.41 2.251,117.41C1.575,117.41 1.035,117.635 0.63,118.086C0.18,118.536 0,119.076 0,119.706C0,120.381 0.18,120.921 0.63,121.371C1.035,121.821 1.575,122.002 2.251,122.002C4.321,122.002 6.166,121.551 7.787,120.651C9.407,119.751 10.667,118.491 11.568,116.87Z" - android:fillColor="#020202"/> - <path - android:pathData="M41.817,98.776C41.817,99.361 41.592,99.856 41.232,100.216C40.827,100.576 40.332,100.757 39.746,100.757H22.642C22.958,102.872 23.903,104.582 25.478,105.843C27.053,107.148 28.989,107.778 31.284,107.778C32.185,107.778 33.085,107.643 34.075,107.283C35.065,106.968 35.875,106.563 36.506,106.068C36.911,105.753 37.406,105.573 37.991,105.573C38.531,105.573 38.981,105.753 39.341,106.023C39.881,106.473 40.152,107.013 40.152,107.553C40.152,108.093 39.881,108.543 39.431,108.903C38.396,109.714 37.136,110.389 35.605,110.884C34.075,111.424 32.635,111.649 31.284,111.649C28.809,111.649 26.603,111.154 24.668,110.074C22.687,108.993 21.157,107.508 20.077,105.618C18.997,103.727 18.456,101.612 18.456,99.181C18.456,96.796 18.951,94.635 19.987,92.745C21.022,90.854 22.462,89.369 24.308,88.289C26.153,87.208 28.269,86.668 30.609,86.668C32.905,86.668 34.885,87.208 36.596,88.199C38.261,89.234 39.566,90.674 40.467,92.475C41.367,94.32 41.817,96.436 41.817,98.776ZM30.609,90.539C28.404,90.539 26.603,91.17 25.253,92.34C23.903,93.555 23.047,95.176 22.687,97.156H37.676C37.406,95.176 36.641,93.555 35.425,92.34C34.165,91.17 32.59,90.539 30.609,90.539Z" - android:fillColor="#020202" - android:fillType="evenOdd"/> - <path - android:pathData="M48.708,87.748V102.737C48.708,104.402 49.023,105.888 49.743,107.193C50.418,108.543 51.363,109.578 52.579,110.344C53.794,111.109 55.144,111.469 56.63,111.469H57.44C58.205,111.469 58.835,111.289 59.33,110.839C59.825,110.434 60.095,109.894 60.095,109.218C60.095,108.588 59.87,108.048 59.51,107.598C59.15,107.193 58.7,106.968 58.16,106.968H56.63C55.639,106.968 54.829,106.563 54.199,105.753C53.524,104.942 53.209,103.952 53.209,102.737V91.619H57.035C57.665,91.619 58.16,91.439 58.565,91.079C58.925,90.719 59.15,90.269 59.15,89.729C59.15,89.144 58.925,88.649 58.565,88.288C58.16,87.928 57.665,87.748 57.035,87.748H53.209V81.267C53.209,80.637 52.984,80.097 52.579,79.646C52.128,79.241 51.588,79.016 50.958,79.016C50.283,79.016 49.743,79.241 49.338,79.646C48.888,80.097 48.708,80.637 48.708,81.267L48.708,87.748Z" - android:fillColor="#020202"/> - <path - android:pathData="M203.209,122.127C202.909,122.127 202.55,122.037 202.13,121.857C200.811,121.257 200.451,120.313 201.051,119.024L205.548,108.906L195.43,89.704C195.13,89.135 195.085,88.595 195.295,88.086C195.535,87.546 195.939,87.156 196.509,86.916C197.079,86.677 197.618,86.662 198.128,86.871C198.637,87.051 199.027,87.426 199.297,87.996L207.526,104.364L214.766,88.041C215.366,86.782 216.31,86.422 217.599,86.961C218.948,87.531 219.323,88.475 218.723,89.794L204.963,120.733C204.603,121.662 204.019,122.127 203.209,122.127Z" - android:fillColor="#020202"/> - <path - android:pathData="M182.43,111.514C179.941,111.514 177.723,110.99 175.774,109.94C173.856,108.861 172.342,107.392 171.232,105.533C170.153,103.645 169.614,101.486 169.614,99.058C169.614,96.6 170.123,94.441 171.142,92.582C172.192,90.694 173.631,89.225 175.459,88.176C177.288,87.096 179.387,86.557 181.755,86.557C184.093,86.557 186.102,87.081 187.781,88.131C189.46,89.15 190.734,90.574 191.603,92.403C192.502,94.201 192.952,96.285 192.952,98.653C192.952,99.223 192.757,99.702 192.368,100.092C191.978,100.452 191.483,100.632 190.884,100.632H173.796C174.125,102.7 175.07,104.394 176.629,105.713C178.217,107.002 180.151,107.647 182.43,107.647C183.359,107.647 184.303,107.482 185.263,107.152C186.252,106.793 187.046,106.388 187.646,105.938C188.096,105.608 188.575,105.444 189.085,105.444C189.624,105.414 190.089,105.563 190.479,105.893C190.989,106.343 191.258,106.838 191.288,107.377C191.318,107.917 191.078,108.381 190.569,108.771C189.55,109.581 188.275,110.24 186.747,110.75C185.248,111.259 183.809,111.514 182.43,111.514ZM181.755,90.424C179.537,90.424 177.753,91.039 176.404,92.268C175.055,93.497 174.2,95.086 173.841,97.034H188.815C188.545,95.116 187.811,93.542 186.612,92.313C185.412,91.054 183.794,90.424 181.755,90.424Z" - android:fillColor="#020202"/> - <path - android:pathData="M155.434,111.29C154.504,111.29 153.785,110.795 153.275,109.806L143.742,89.705C143.502,89.195 143.472,88.701 143.652,88.221C143.862,87.711 144.251,87.321 144.821,87.052C145.331,86.782 145.84,86.737 146.35,86.917C146.89,87.097 147.294,87.441 147.564,87.951L155.389,104.814L163.123,87.951C163.393,87.441 163.798,87.097 164.337,86.917C164.907,86.737 165.477,86.782 166.046,87.052C166.586,87.291 166.946,87.666 167.125,88.176C167.305,88.686 167.275,89.195 167.035,89.705L157.502,109.806C157.052,110.795 156.363,111.29 155.434,111.29Z" - android:fillColor="#020202"/> - <path - android:pathData="M124.579,111.335C123.08,111.335 122.331,110.585 122.331,109.086V88.985C122.331,87.486 123.08,86.737 124.579,86.737C126.078,86.737 125.464,86.737 126.458,86.737L127.291,86.737C128.34,86.737 128.748,86.737 129.997,86.737C131.246,86.737 132.84,86.737 134.369,86.737C136.168,86.737 137.515,86.767 138.384,87.366C139.284,87.936 139.629,88.626 139.419,89.435C139.269,90.064 138.969,90.484 138.519,90.694C138.07,90.874 137.545,90.904 136.945,90.784C135.027,90.394 133.303,90.364 131.774,90.694C130.245,91.024 129.031,91.638 128.132,92.538C127.262,93.437 126.828,94.576 126.828,95.955V109.086C126.828,110.585 126.078,111.335 124.579,111.335Z" - android:fillColor="#020202"/> - <path - android:pathData="M76.211,111.514C74.112,111.514 72.163,111.199 70.365,110.57C68.596,109.91 67.217,109.086 66.228,108.097C65.778,107.617 65.583,107.077 65.643,106.478C65.733,105.848 66.033,105.339 66.542,104.949C67.142,104.469 67.726,104.274 68.296,104.364C68.896,104.424 69.405,104.679 69.825,105.129C70.335,105.698 71.144,106.238 72.253,106.748C73.393,107.227 74.652,107.467 76.031,107.467C77.769,107.467 79.089,107.182 79.988,106.613C80.917,106.043 81.397,105.309 81.427,104.409C81.457,103.51 81.022,102.73 80.123,102.071C79.253,101.411 77.649,100.872 75.311,100.452C72.283,99.852 70.08,98.953 68.701,97.754C67.352,96.555 66.677,95.086 66.677,93.347C66.677,91.818 67.127,90.559 68.026,89.57C68.926,88.55 70.08,87.801 71.489,87.321C72.898,86.812 74.367,86.557 75.896,86.557C77.874,86.557 79.628,86.871 81.157,87.501C82.686,88.131 83.9,89 84.799,90.109C85.219,90.589 85.414,91.099 85.384,91.638C85.354,92.148 85.099,92.582 84.62,92.942C84.14,93.272 83.57,93.377 82.911,93.257C82.251,93.137 81.697,92.867 81.247,92.448C80.497,91.728 79.688,91.233 78.819,90.964C77.949,90.694 76.945,90.559 75.806,90.559C74.487,90.559 73.363,90.784 72.433,91.233C71.534,91.683 71.084,92.343 71.084,93.212C71.084,93.752 71.219,94.246 71.489,94.696C71.789,95.116 72.358,95.506 73.198,95.865C74.037,96.195 75.266,96.525 76.885,96.854C80.273,97.514 82.611,98.458 83.9,99.688C85.219,100.887 85.879,102.371 85.879,104.139C85.879,105.518 85.504,106.763 84.755,107.872C84.035,108.981 82.956,109.865 81.517,110.525C80.108,111.184 78.339,111.514 76.211,111.514Z" - android:fillColor="#020202"/> - <path - android:pathData="M92.461,106.348C93.407,108.059 94.712,109.409 96.332,110.309C97.908,111.119 99.618,111.57 101.508,111.615C101.553,111.615 101.644,111.66 101.644,111.66H111.996C112.626,111.66 113.121,111.435 113.526,111.029C113.976,110.579 114.156,110.084 114.156,109.499V109.319V109.139V89.064C114.156,88.389 113.976,87.849 113.526,87.399C113.076,86.949 112.536,86.769 111.861,86.769C111.186,86.769 110.646,86.949 110.196,87.399C109.79,87.849 109.565,88.389 109.565,89.064V101.757C109.565,102.838 109.295,103.783 108.71,104.683C108.125,105.538 107.315,106.213 106.28,106.753C105.244,107.249 104.074,107.519 102.814,107.519C100.653,107.519 98.943,106.888 97.638,105.628C96.332,104.368 95.657,102.522 95.657,100.137V89.064C95.657,88.389 95.477,87.849 95.027,87.399C94.577,86.949 94.037,86.769 93.362,86.769C92.731,86.769 92.191,86.949 91.741,87.399C91.291,87.849 91.066,88.389 91.066,89.064V100.137C91.066,102.567 91.561,104.638 92.461,106.348Z" - android:fillColor="#020202"/> -</vector> diff --git a/Jetsurvey/app/src/main/res/drawable/ic_selfie_dark.xml b/Jetsurvey/app/src/main/res/drawable/ic_selfie_dark.xml deleted file mode 100644 index 504306467e..0000000000 --- a/Jetsurvey/app/src/main/res/drawable/ic_selfie_dark.xml +++ /dev/null @@ -1,107 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="200dp" - android:height="162dp" - android:viewportWidth="200" - android:viewportHeight="162"> - <group> - <path - android:pathData="M77.957,160.547L200,99L150.073,0L23.608,57.867L77.957,160.547Z" - android:strokeAlpha="0.9" - android:fillColor="#3C4043" - android:fillAlpha="0.9"/> - <path - android:pathData="M163.568,125.799C167.692,125.799 171.035,122.456 171.035,118.332C171.035,114.208 167.692,110.865 163.568,110.865C159.444,110.865 156.101,114.208 156.101,118.332C156.101,122.456 159.444,125.799 163.568,125.799Z" - android:strokeAlpha="0.25" - android:fillColor="#9AA0A6" - android:fillAlpha="0.25"/> - <path - android:pathData="M149.749,127.136C152.642,127.136 154.987,124.791 154.987,121.898C154.987,119.005 152.642,116.66 149.749,116.66C146.856,116.66 144.511,119.005 144.511,121.898C144.511,124.791 146.856,127.136 149.749,127.136Z" - android:strokeAlpha="0.25" - android:fillColor="#9AA0A6" - android:fillAlpha="0.25"/> - <path - android:pathData="M41.563,119.974L21.283,108.265L9.574,128.545L29.854,140.254L41.563,119.974Z" - android:strokeAlpha="0.9" - android:fillColor="#3C4043" - android:fillAlpha="0.9"/> - <path - android:pathData="M141.225,75.99C140.185,75.99 139.168,75.681 138.304,75.104C137.439,74.526 136.765,73.705 136.368,72.744C135.97,71.784 135.866,70.727 136.068,69.707C136.271,68.687 136.772,67.751 137.507,67.015C138.242,66.28 139.179,65.779 140.199,65.577C141.219,65.374 142.276,65.478 143.236,65.876C144.197,66.274 145.018,66.948 145.596,67.812C146.173,68.676 146.482,69.693 146.482,70.733C146.48,72.127 145.926,73.463 144.94,74.448C143.955,75.434 142.618,75.988 141.225,75.99ZM141.225,65.953C140.279,65.953 139.355,66.234 138.569,66.759C137.783,67.284 137.171,68.031 136.809,68.904C136.447,69.777 136.353,70.738 136.537,71.665C136.722,72.592 137.177,73.444 137.845,74.112C138.513,74.78 139.365,75.236 140.292,75.42C141.219,75.604 142.18,75.51 143.053,75.148C143.927,74.786 144.673,74.174 145.198,73.388C145.723,72.602 146.004,71.678 146.004,70.733C146.002,69.466 145.498,68.251 144.602,67.355C143.706,66.459 142.492,65.955 141.225,65.953Z" - android:strokeAlpha="0.12" - android:fillColor="#ffffff" - android:fillAlpha="0.12"/> - <path - android:pathData="M51.137,60.935C50.097,60.935 49.081,60.627 48.216,60.049C47.352,59.471 46.678,58.65 46.28,57.69C45.882,56.729 45.778,55.672 45.981,54.652C46.184,53.633 46.685,52.696 47.42,51.961C48.155,51.225 49.092,50.725 50.111,50.522C51.131,50.319 52.188,50.423 53.149,50.821C54.11,51.219 54.931,51.893 55.508,52.757C56.086,53.622 56.394,54.638 56.394,55.678C56.393,57.072 55.838,58.408 54.853,59.394C53.867,60.379 52.531,60.933 51.137,60.935ZM51.137,50.899C50.192,50.899 49.268,51.179 48.482,51.704C47.696,52.229 47.083,52.976 46.722,53.849C46.36,54.722 46.265,55.683 46.45,56.61C46.634,57.537 47.089,58.389 47.758,59.057C48.426,59.726 49.278,60.181 50.205,60.365C51.132,60.55 52.093,60.455 52.966,60.093C53.839,59.732 54.586,59.119 55.111,58.333C55.636,57.547 55.916,56.623 55.916,55.678C55.915,54.411 55.411,53.196 54.515,52.3C53.619,51.404 52.404,50.9 51.137,50.899Z" - android:strokeAlpha="0.12" - android:fillColor="#ffffff" - android:fillAlpha="0.12"/> - <path - android:pathData="M30.348,144.332C29.308,144.332 28.292,144.023 27.427,143.446C26.563,142.868 25.889,142.047 25.491,141.086C25.093,140.126 24.989,139.069 25.192,138.049C25.395,137.029 25.895,136.092 26.63,135.357C27.366,134.622 28.302,134.121 29.322,133.918C30.342,133.716 31.399,133.82 32.36,134.218C33.32,134.615 34.141,135.289 34.719,136.154C35.297,137.018 35.605,138.035 35.605,139.074C35.603,140.468 35.049,141.805 34.063,142.79C33.078,143.776 31.742,144.33 30.348,144.332ZM30.348,134.295C29.403,134.295 28.479,134.576 27.693,135.101C26.907,135.626 26.294,136.372 25.932,137.246C25.571,138.119 25.476,139.08 25.66,140.007C25.845,140.934 26.3,141.785 26.968,142.454C27.637,143.122 28.488,143.577 29.415,143.762C30.342,143.946 31.303,143.852 32.177,143.49C33.05,143.128 33.796,142.516 34.321,141.73C34.847,140.944 35.127,140.02 35.127,139.074C35.125,137.807 34.621,136.593 33.725,135.697C32.83,134.801 31.615,134.297 30.348,134.295Z" - android:strokeAlpha="0.12" - android:fillColor="#ffffff" - android:fillAlpha="0.12"/> - <path - android:pathData="M78.691,58.822C81.675,55.471 82.457,51.296 80.437,49.496C78.416,47.697 74.359,48.955 71.375,52.306C68.391,55.657 67.609,59.832 69.63,61.631C71.65,63.43 75.707,62.173 78.691,58.822Z" - android:fillColor="#2F2E41"/> - <path - android:pathData="M90.446,91.886C102.191,91.886 111.713,82.365 111.713,70.619C111.713,58.873 102.191,49.352 90.446,49.352C78.7,49.352 69.178,58.873 69.178,70.619C69.178,82.365 78.7,91.886 90.446,91.886Z" - android:fillColor="#2F2E41"/> - <path - android:pathData="M28.296,61.245L8.528,62.863C7.75,62.926 7.021,63.266 6.471,63.82C5.922,64.375 5.589,65.108 5.532,65.886L4.794,76.072C4.762,76.52 4.822,76.97 4.971,77.394C5.121,77.818 5.356,78.207 5.662,78.536C5.968,78.865 6.338,79.128 6.751,79.307C7.163,79.487 7.607,79.579 8.057,79.579H16.966V80.057H20.551V79.579H22.462V80.057H26.046V79.579H27.957C28.796,79.579 29.602,79.257 30.21,78.68C30.818,78.102 31.181,77.313 31.224,76.476L31.829,64.673C31.853,64.207 31.777,63.742 31.606,63.308C31.436,62.874 31.174,62.482 30.84,62.158C30.505,61.833 30.105,61.584 29.667,61.426C29.228,61.269 28.76,61.207 28.296,61.245Z" - android:fillColor="#202124"/> - <path - android:pathData="M40.862,93.44L12.904,80.297L7.886,88.183C7.886,88.183 22.423,97.463 23.179,98.219C24.254,99.294 35.844,106.821 40.384,105.626C44.924,104.432 42.774,94.634 40.862,93.44Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M59.979,153.657L56.394,161.781H93.5L90.804,153.179L59.979,153.657Z" - android:fillColor="#2F2E41"/> - <path - android:pathData="M82.68,87.704C82.68,87.704 84.113,95.59 82.68,96.546C81.246,97.502 92.238,109.21 92.238,109.21L96.778,105.148L98.212,95.829C98.212,95.829 93.911,89.855 95.822,84.598C97.734,79.341 82.68,87.704 82.68,87.704Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M86.981,90.093C95.427,90.093 102.274,83.246 102.274,74.8C102.274,66.354 95.427,59.507 86.981,59.507C78.535,59.507 71.688,66.354 71.688,74.8C71.688,83.246 78.535,90.093 86.981,90.093Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M91.043,103.715C91.043,103.715 85.069,94.634 81.724,93.917C78.378,93.2 75.272,93.678 73.36,93.917C71.449,94.156 64.997,91.767 61.173,93.439C57.35,95.112 61.173,114.707 61.173,114.707C61.173,114.707 56.872,139.797 58.784,146.249C60.695,152.701 59.262,153.896 58.306,154.613C57.35,155.33 80.051,161.782 80.051,161.782H101.557C101.557,161.782 101.557,154.852 103.708,153.179C105.859,151.506 105.62,116.14 105.62,116.14L112.788,102.281C112.788,102.281 111.594,97.024 106.576,96.785C101.557,96.546 97.078,93.911 97.078,93.911L93.672,103.715H91.043Z" - android:fillColor="#FFD083"/> - <path - android:pathData="M63.324,94.634L61.173,93.439C61.173,93.439 57.111,93.678 56.394,93.439C55.677,93.2 46.597,90.333 43.013,92.006C39.428,93.678 37.039,91.528 37.039,91.528C37.039,91.528 48.748,101.564 34.41,104.67C34.41,104.67 34.649,105.865 38.233,106.582C41.818,107.299 42.535,108.016 43.968,109.211C45.402,110.405 48.27,111.6 50.181,112.078C52.093,112.556 58.306,116.618 59.262,115.662C60.218,114.707 61.89,111.361 61.89,111.361L63.324,94.634Z" - android:fillColor="#FFD083"/> - <path - android:pathData="M108.248,99.652L112.788,102.281C112.788,102.281 118.762,119.964 117.807,124.265C116.851,128.566 115.895,127.849 117.329,130.717C118.762,133.584 120.196,133.823 119.24,136.452C118.284,139.081 118.762,145.532 117.329,148.161C115.895,150.789 108.965,159.153 102.991,145.054C97.017,130.956 108.248,99.652 108.248,99.652Z" - android:fillColor="#FFD083"/> - <path - android:pathData="M116.851,136.213C116.851,136.213 113.505,129.522 112.788,129.044C112.072,128.566 108.726,112.078 108.726,112.078C108.726,112.078 113.983,103.476 109.204,99.652C109.204,99.652 118.046,87.227 115.178,87.466C112.311,87.704 106.576,96.785 106.576,96.785C106.576,96.785 105.261,96.426 105.739,94.515C106.217,92.603 108.009,85.793 106.576,85.554C105.142,85.315 102.274,95.59 102.274,95.59C102.274,95.59 96.42,106.224 98.212,109.928C99.255,112.006 100.453,114.003 101.796,115.902L105.859,145.533C105.859,145.533 113.744,155.091 116.851,136.213Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M26.763,65.72C28.215,65.72 29.392,65.185 29.392,64.525C29.392,63.865 28.215,63.33 26.763,63.33C25.312,63.33 24.135,63.865 24.135,64.525C24.135,65.185 25.312,65.72 26.763,65.72Z" - android:fillColor="#FFD083"/> - <path - android:pathData="M10.753,88.182H7.408C7.408,88.182 0,85.793 0,80.297C0,74.8 2.151,63.569 8.841,63.808C15.532,64.047 9.319,78.863 9.319,78.863L13.37,80.516L13.382,85.554L10.753,88.182Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M84.472,71.574C91.994,71.574 98.092,68.472 98.092,64.645C98.092,60.817 91.994,57.715 84.472,57.715C76.949,57.715 70.851,60.817 70.851,64.645C70.851,68.472 76.949,71.574 84.472,71.574Z" - android:fillColor="#2F2E41"/> - <path - android:pathData="M102.872,75.875C103.532,75.875 104.066,74.698 104.066,73.247C104.066,71.795 103.532,70.618 102.872,70.618C102.212,70.618 101.677,71.795 101.677,73.247C101.677,74.698 102.212,75.875 102.872,75.875Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M113.47,66.696C115.904,65.514 116.288,61.284 114.327,57.247C112.367,53.211 108.806,50.897 106.372,52.079C103.938,53.261 103.554,57.491 105.514,61.527C107.475,65.563 111.036,67.878 113.47,66.696Z" - android:fillColor="#2F2E41"/> - </group> -</vector> diff --git a/Jetsurvey/app/src/main/res/drawable/ic_selfie_light.xml b/Jetsurvey/app/src/main/res/drawable/ic_selfie_light.xml deleted file mode 100644 index bd7c7fa611..0000000000 --- a/Jetsurvey/app/src/main/res/drawable/ic_selfie_light.xml +++ /dev/null @@ -1,97 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="200dp" - android:height="162dp" - android:viewportWidth="200" - android:viewportHeight="162"> - <group> - <path - android:pathData="M77.957,160.547L200,99L150.073,0L23.608,57.867L77.957,160.547Z" - android:fillColor="#F2F2F2"/> - <path - android:pathData="M163.568,125.799C167.692,125.799 171.035,122.456 171.035,118.332C171.035,114.208 167.692,110.865 163.568,110.865C159.444,110.865 156.101,114.208 156.101,118.332C156.101,122.456 159.444,125.799 163.568,125.799Z" - android:strokeAlpha="0.4" - android:fillColor="#F88307" - android:fillAlpha="0.4"/> - <path - android:pathData="M149.749,127.136C152.642,127.136 154.987,124.791 154.987,121.898C154.987,119.005 152.642,116.66 149.749,116.66C146.856,116.66 144.511,119.005 144.511,121.898C144.511,124.791 146.856,127.136 149.749,127.136Z" - android:strokeAlpha="0.4" - android:fillColor="#F88307" - android:fillAlpha="0.4"/> - <path - android:pathData="M41.563,119.974L21.283,108.265L9.574,128.545L29.854,140.254L41.563,119.974Z" - android:fillColor="#F2F2F2"/> - <path - android:pathData="M141.225,75.99C140.185,75.99 139.168,75.681 138.304,75.104C137.439,74.526 136.765,73.705 136.368,72.744C135.97,71.784 135.866,70.727 136.068,69.707C136.271,68.687 136.772,67.751 137.507,67.015C138.242,66.28 139.179,65.779 140.199,65.577C141.219,65.374 142.276,65.478 143.236,65.876C144.197,66.274 145.018,66.948 145.596,67.812C146.173,68.676 146.482,69.693 146.482,70.733C146.48,72.127 145.926,73.463 144.94,74.448C143.955,75.434 142.618,75.988 141.225,75.99ZM141.225,65.953C140.279,65.953 139.355,66.234 138.569,66.759C137.783,67.284 137.171,68.031 136.809,68.904C136.447,69.777 136.353,70.738 136.537,71.665C136.722,72.592 137.177,73.444 137.845,74.112C138.513,74.78 139.365,75.236 140.292,75.42C141.219,75.604 142.18,75.51 143.053,75.148C143.927,74.786 144.673,74.174 145.198,73.388C145.723,72.602 146.004,71.678 146.004,70.733C146.002,69.466 145.498,68.251 144.602,67.355C143.706,66.459 142.492,65.955 141.225,65.953Z" - android:fillColor="#3F3D56"/> - <path - android:pathData="M51.137,60.935C50.097,60.935 49.081,60.627 48.216,60.049C47.352,59.471 46.678,58.65 46.28,57.69C45.882,56.729 45.778,55.672 45.981,54.652C46.184,53.633 46.685,52.696 47.42,51.961C48.155,51.225 49.092,50.725 50.111,50.522C51.131,50.319 52.188,50.423 53.149,50.821C54.11,51.219 54.931,51.893 55.508,52.757C56.086,53.622 56.394,54.638 56.394,55.678C56.393,57.072 55.838,58.408 54.853,59.394C53.867,60.379 52.531,60.933 51.137,60.935ZM51.137,50.899C50.192,50.899 49.268,51.179 48.482,51.704C47.696,52.229 47.083,52.976 46.722,53.849C46.36,54.722 46.265,55.683 46.45,56.61C46.634,57.537 47.089,58.389 47.758,59.057C48.426,59.726 49.278,60.181 50.205,60.365C51.132,60.55 52.093,60.455 52.966,60.093C53.839,59.732 54.586,59.119 55.111,58.333C55.636,57.547 55.916,56.623 55.916,55.678C55.915,54.411 55.411,53.196 54.515,52.3C53.619,51.404 52.404,50.9 51.137,50.899Z" - android:fillColor="#3F3D56"/> - <path - android:pathData="M30.348,144.332C29.308,144.332 28.292,144.023 27.427,143.446C26.563,142.868 25.889,142.047 25.491,141.086C25.093,140.126 24.989,139.069 25.192,138.049C25.395,137.029 25.895,136.092 26.63,135.357C27.366,134.622 28.302,134.121 29.322,133.918C30.342,133.716 31.399,133.82 32.36,134.218C33.32,134.615 34.141,135.289 34.719,136.154C35.297,137.018 35.605,138.035 35.605,139.074C35.603,140.468 35.049,141.805 34.063,142.79C33.078,143.776 31.742,144.33 30.348,144.332ZM30.348,134.295C29.403,134.295 28.479,134.576 27.693,135.101C26.907,135.626 26.294,136.372 25.932,137.246C25.571,138.119 25.476,139.08 25.66,140.007C25.845,140.934 26.3,141.785 26.968,142.454C27.637,143.122 28.488,143.577 29.415,143.762C30.342,143.946 31.303,143.852 32.177,143.49C33.05,143.128 33.796,142.516 34.321,141.73C34.847,140.944 35.127,140.02 35.127,139.074C35.125,137.807 34.621,136.593 33.726,135.697C32.83,134.801 31.615,134.297 30.348,134.295Z" - android:fillColor="#3F3D56"/> - <path - android:pathData="M78.691,58.822C81.675,55.471 82.457,51.296 80.437,49.496C78.416,47.697 74.359,48.955 71.375,52.306C68.391,55.657 67.609,59.832 69.63,61.631C71.65,63.43 75.707,62.173 78.691,58.822Z" - android:fillColor="#2F2E41"/> - <path - android:pathData="M90.446,91.886C102.191,91.886 111.713,82.365 111.713,70.619C111.713,58.873 102.191,49.352 90.446,49.352C78.7,49.352 69.178,58.873 69.178,70.619C69.178,82.365 78.7,91.886 90.446,91.886Z" - android:fillColor="#2F2E41"/> - <path - android:pathData="M28.296,61.245L8.528,62.863C7.75,62.926 7.021,63.266 6.471,63.82C5.922,64.375 5.589,65.108 5.532,65.886L4.794,76.072C4.762,76.52 4.822,76.97 4.971,77.394C5.121,77.818 5.356,78.207 5.662,78.536C5.968,78.865 6.338,79.128 6.751,79.307C7.163,79.487 7.607,79.579 8.057,79.579H16.966V80.057H20.551V79.579H22.462V80.057H26.046V79.579H27.957C28.796,79.579 29.602,79.257 30.21,78.68C30.818,78.102 31.181,77.313 31.224,76.476L31.829,64.673C31.853,64.207 31.777,63.742 31.606,63.308C31.436,62.874 31.174,62.482 30.84,62.158C30.505,61.833 30.105,61.584 29.667,61.426C29.228,61.269 28.76,61.207 28.296,61.245Z" - android:fillColor="#2F2E41"/> - <path - android:pathData="M40.862,93.44L12.904,80.297L7.886,88.183C7.886,88.183 22.423,97.463 23.179,98.219C24.254,99.294 35.844,106.821 40.384,105.626C44.924,104.432 42.774,94.634 40.862,93.44Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M59.979,153.657L56.394,161.781H93.5L90.804,153.179L59.979,153.657Z" - android:fillColor="#2F2E41"/> - <path - android:pathData="M82.68,87.704C82.68,87.704 84.113,95.59 82.68,96.546C81.246,97.502 92.238,109.21 92.238,109.21L96.778,105.148L98.212,95.829C98.212,95.829 93.911,89.855 95.822,84.598C97.734,79.341 82.68,87.704 82.68,87.704Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M86.981,90.093C95.427,90.093 102.274,83.246 102.274,74.8C102.274,66.354 95.427,59.507 86.981,59.507C78.535,59.507 71.688,66.354 71.688,74.8C71.688,83.246 78.535,90.093 86.981,90.093Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M91.043,103.715C91.043,103.715 85.069,94.634 81.724,93.917C78.378,93.2 75.272,93.678 73.36,93.917C71.449,94.156 64.997,91.767 61.173,93.439C57.35,95.112 61.173,114.707 61.173,114.707C61.173,114.707 56.872,139.797 58.784,146.249C60.695,152.701 59.262,153.896 58.306,154.613C57.35,155.33 80.051,161.782 80.051,161.782H101.557C101.557,161.782 101.557,154.852 103.708,153.179C105.859,151.506 105.62,116.14 105.62,116.14L112.788,102.281C112.788,102.281 111.594,97.024 106.576,96.785C101.557,96.546 97.078,93.911 97.078,93.911L93.672,103.715H91.043Z" - android:fillColor="#F88307"/> - <path - android:pathData="M63.324,94.634L61.173,93.439C61.173,93.439 57.111,93.678 56.394,93.439C55.677,93.2 46.597,90.333 43.013,92.006C39.428,93.678 37.039,91.528 37.039,91.528C37.039,91.528 48.748,101.564 34.41,104.67C34.41,104.67 34.649,105.865 38.233,106.582C41.818,107.299 42.535,108.016 43.968,109.211C45.402,110.405 48.27,111.6 50.181,112.078C52.093,112.556 58.306,116.618 59.262,115.662C60.218,114.707 61.89,111.361 61.89,111.361L63.324,94.634Z" - android:fillColor="#F88307"/> - <path - android:pathData="M108.248,99.652L112.788,102.281C112.788,102.281 118.762,119.964 117.807,124.265C116.851,128.566 115.895,127.849 117.329,130.717C118.762,133.584 120.196,133.823 119.24,136.452C118.284,139.081 118.762,145.532 117.329,148.161C115.895,150.789 108.965,159.153 102.991,145.054C97.017,130.956 108.248,99.652 108.248,99.652Z" - android:fillColor="#F88307"/> - <path - android:pathData="M116.851,136.213C116.851,136.213 113.505,129.522 112.788,129.044C112.072,128.566 108.726,112.078 108.726,112.078C108.726,112.078 113.983,103.476 109.204,99.652C109.204,99.652 118.046,87.227 115.178,87.466C112.311,87.704 106.576,96.785 106.576,96.785C106.576,96.785 105.261,96.426 105.739,94.515C106.217,92.603 108.009,85.793 106.576,85.554C105.142,85.315 102.274,95.59 102.274,95.59C102.274,95.59 96.42,106.224 98.212,109.928C99.255,112.006 100.453,114.003 101.796,115.902L105.859,145.533C105.859,145.533 113.744,155.091 116.851,136.213Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M26.763,65.72C28.215,65.72 29.392,65.185 29.392,64.525C29.392,63.865 28.215,63.33 26.763,63.33C25.312,63.33 24.135,63.865 24.135,64.525C24.135,65.185 25.312,65.72 26.763,65.72Z" - android:fillColor="#F88307"/> - <path - android:pathData="M10.753,88.182H7.408C7.408,88.182 0,85.793 0,80.297C0,74.8 2.151,63.569 8.841,63.808C15.532,64.047 9.319,78.863 9.319,78.863L13.37,80.516L13.382,85.554L10.753,88.182Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M84.472,71.574C91.994,71.574 98.092,68.472 98.092,64.645C98.092,60.817 91.994,57.715 84.472,57.715C76.949,57.715 70.851,60.817 70.851,64.645C70.851,68.472 76.949,71.574 84.472,71.574Z" - android:fillColor="#2F2E41"/> - <path - android:pathData="M102.872,75.875C103.532,75.875 104.066,74.698 104.066,73.247C104.066,71.795 103.532,70.618 102.872,70.618C102.212,70.618 101.677,71.795 101.677,73.247C101.677,74.698 102.212,75.875 102.872,75.875Z" - android:fillColor="#9F616A"/> - <path - android:pathData="M113.47,66.696C115.904,65.514 116.288,61.284 114.327,57.247C112.367,53.211 108.806,50.897 106.372,52.079C103.938,53.261 103.554,57.491 105.514,61.527C107.475,65.563 111.036,67.878 113.47,66.696Z" - android:fillColor="#2F2E41"/> - </group> -</vector> diff --git a/Jetsurvey/app/src/main/res/font/montserrat_medium.ttf b/Jetsurvey/app/src/main/res/font/montserrat_medium.ttf deleted file mode 100755 index 6e079f6984..0000000000 Binary files a/Jetsurvey/app/src/main/res/font/montserrat_medium.ttf and /dev/null differ diff --git a/Jetsurvey/app/src/main/res/font/montserrat_regular.ttf b/Jetsurvey/app/src/main/res/font/montserrat_regular.ttf deleted file mode 100755 index 8d443d5d56..0000000000 Binary files a/Jetsurvey/app/src/main/res/font/montserrat_regular.ttf and /dev/null differ diff --git a/Jetsurvey/app/src/main/res/font/montserrat_semibold.ttf b/Jetsurvey/app/src/main/res/font/montserrat_semibold.ttf deleted file mode 100755 index f8a43f2b20..0000000000 Binary files a/Jetsurvey/app/src/main/res/font/montserrat_semibold.ttf and /dev/null differ diff --git a/Jetsurvey/app/src/main/res/layout/activity_main.xml b/Jetsurvey/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index d75ae2da63..0000000000 --- a/Jetsurvey/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,33 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - ~ - --> -<LinearLayout - xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - - <include layout="@layout/content_main" /> - - <com.google.android.material.navigation.NavigationView - android:id="@+id/nav_view" - style="@style/Widget.MaterialComponents.NavigationView" - android:layout_width="wrap_content" - android:layout_height="match_parent" - android:layout_gravity="start" - android:fitsSystemWindows="true" /> -</LinearLayout> \ No newline at end of file diff --git a/Jetsurvey/app/src/main/res/layout/content_main.xml b/Jetsurvey/app/src/main/res/layout/content_main.xml deleted file mode 100644 index 18500a4aae..0000000000 --- a/Jetsurvey/app/src/main/res/layout/content_main.xml +++ /dev/null @@ -1,30 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - ~ - --> -<merge xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <fragment - android:id="@+id/nav_host_fragment" - android:name="androidx.navigation.fragment.NavHostFragment" - android:layout_width="match_parent" - android:layout_height="match_parent" - app:defaultNavHost="true" - app:navGraph="@navigation/nav_graph" /> -</merge> diff --git a/Jetsurvey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Jetsurvey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2a520286f9..0000000000 --- a/Jetsurvey/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,21 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - ~ - --> -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@drawable/ic_launcher_background" /> - <foreground android:drawable="@drawable/ic_launcher_foreground" /> -</adaptive-icon> diff --git a/Jetsurvey/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Jetsurvey/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 1adb5a343f..0000000000 Binary files a/Jetsurvey/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Jetsurvey/app/src/main/res/navigation/nav_graph.xml b/Jetsurvey/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index 0f3eb7df82..0000000000 --- a/Jetsurvey/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,43 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright 2020 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 - ~ - ~ 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. - ~ - --> -<navigation xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/nav_graph" - app:startDestination="@+id/welcome_fragment"> - <fragment - android:id="@+id/welcome_fragment" - android:name="com.example.compose.jetsurvey.signinsignup.WelcomeFragment" - android:label="Welcome"> - </fragment> - <fragment - android:id="@+id/sign_in_fragment" - android:name="com.example.compose.jetsurvey.signinsignup.SignInFragment" - android:label="Sign in"> - </fragment> - <fragment - android:id="@+id/sign_up_fragment" - android:name="com.example.compose.jetsurvey.signinsignup.SignUpFragment" - android:label="Sign up"> - </fragment> - <fragment - android:id="@+id/survey_fragment" - android:name="com.example.compose.jetsurvey.survey.SurveyFragment" - android:label="Survey"> - </fragment> -</navigation> \ No newline at end of file diff --git a/Jetsurvey/app/src/main/res/values-night/colors.xml b/Jetsurvey/app/src/main/res/values-night/colors.xml deleted file mode 100644 index affcce749d..0000000000 --- a/Jetsurvey/app/src/main/res/values-night/colors.xml +++ /dev/null @@ -1,19 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<resources> - <color name="status_bar_color">#000000</color> -</resources> diff --git a/Jetsurvey/app/src/main/res/values/colors.xml b/Jetsurvey/app/src/main/res/values/colors.xml deleted file mode 100644 index 4a9aadb241..0000000000 --- a/Jetsurvey/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,19 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<resources> - <color name="status_bar_color">#EFEFEF</color> -</resources> diff --git a/Jetsurvey/app/src/main/res/values/ids.xml b/Jetsurvey/app/src/main/res/values/ids.xml deleted file mode 100644 index 9d6aec630f..0000000000 --- a/Jetsurvey/app/src/main/res/values/ids.xml +++ /dev/null @@ -1,22 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<resources> - <item name="welcome_fragment" type="id" /> - <item name="sign_in_fragment" type="id" /> - <item name="sign_up_fragment" type="id" /> - <item name="survey_fragment" type="id" /> -</resources> \ No newline at end of file diff --git a/Jetsurvey/app/src/main/res/values/strings.xml b/Jetsurvey/app/src/main/res/values/strings.xml deleted file mode 100644 index 2417fde8d0..0000000000 --- a/Jetsurvey/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,91 +0,0 @@ -<!-- - ~ Copyright 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License. - ~ - --> -<resources> - <string name="app_name">Jetsurvey</string> - <string name="app_tagline">Better surveys with Jetpack Compose</string> - <string name="email">Email</string> - <string name="password">Password</string> - <string name="confirm_password">Confirm password</string> - <string name="sign_in">Sign in</string> - <string name="sign_in_action">SIGN IN</string> - <string name="sign_in_guest">SIGN IN AS GUEST</string> - <string name="sign_in_create_account">Sign in or create an account</string> - <string name="or">or</string> - <string name="forgot_password">FORGOT PASSWORD?</string> - <string name="user_continue">CONTINUE</string> - <string name="create_account">CREATE ACCOUNT</string> - <string name="terms_and_conditions">By continuing, you agree to our Terms of Service. We’ll - handle your data according to our Privacy Policy.</string> - <string name="feature_not_available">Feature not available</string> - <string name="dismiss">DISMISS</string> - <string name="next">NEXT</string> - <string name="previous">PREVIOUS</string> - <string name="done">DONE</string> - - <!-- survey--> - <string name="which_jetpack_library">Which Jetpack library are you?</string> - <string name="question_count">\u00A0of %d</string> - <string name="select_one">Select one.</string> - <string name="select_all">Select all that apply.</string> - <string name="select_date">Select date.</string> - - <!-- question 1--> - <string name="in_my_free_time">In my free time I like to …</string> - <string name="read">Read</string> - <string name="work_out">Work out</string> - <string name="draw">Draw</string> - <string name="play_games">Play video games</string> - <string name="dance">Dance</string> - <string name="watch_movies">Watch movies</string> - - <!-- question 2 --> - <string name="pick_superhero">Pick a superhero</string> - <string name="spiderman">Spider man (Avengers)</string> - <string name="ironman">Iron man (Avengers)</string> - <string name="unikitty">Uni-kitty (Lego Movie)</string> - <string name="captain_planet">Captain Planet</string> - - <!-- question 3 --> - <string name="takeaway">When was the last time you ordered takeaway because you couldn\'t be bothered to cook?</string> - <string name="pick_date">Pick a date</string> - <string name="selected_date">🥡 📅 %s</string> - - <!-- question 4--> - <string name="selfies">How do you feel about selfies 🤳?</string> - <string name="selfie_min">😒️</string> - <string name="selfie_max">🤩️</string> - - <!-- question 5--> - <string name="selfie_skills">Show off your selfie skills!</string> - <string name="add_photo">ADD PHOTO</string> - <string name="retake_photo">RETAKE PHOTO</string> - - <!-- question 6--> - <string name="best_friend">Who\'s your best friend?</string> - <string name="pick_contact">Pick a contact</string> - - <!-- question 7--> - <string name="favourite_movie">What\'s your favourite movie?</string> - <string name="star_trek">Star Trek</string> - <string name="social_network">The social network</string> - <string name="back_to_future">Back to the future</string> - <string name="outbreak">Outbreak</string> - - <string name="survey_result">Congratulations, you are %s</string> - <string name="survey_result_description">You are a curious developer, always willing to try - something new. You want to stay up to date with the trends to Compose is your middle name</string> -</resources> diff --git a/Jetsurvey/app/src/main/res/values/themes.xml b/Jetsurvey/app/src/main/res/values/themes.xml deleted file mode 100644 index d9d8e56ede..0000000000 --- a/Jetsurvey/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,24 +0,0 @@ -<!-- - ~ 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. - ~ - --> -<resources> - <!-- Base application theme. --> - <style name="Theme.Jetsurvey" parent="Theme.MaterialComponents.DayNight.NoActionBar"> - <item name="android:statusBarColor">@color/status_bar_color</item> - <item name="android:windowLightStatusBar">?attr/isLightTheme</item> - <item name="android:windowBackground">?attr/colorSurface</item> - </style> -</resources> diff --git a/Jetsurvey/build.gradle b/Jetsurvey/build.gradle deleted file mode 100644 index 27e545cee7..0000000000 --- a/Jetsurvey/build.gradle +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.compose.jetsurvey.buildsrc.Libs -import com.example.compose.jetsurvey.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.7.0' -} - -subprojects { - repositories { - google() - mavenCentral() - jcenter() - - if (Libs.AndroidX.Compose.version.endsWith("SNAPSHOT")) { - maven { url Libs.AndroidX.Compose.snapshotUrl } - } - - maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - - ktlint(Versions.ktlint).userData([android: "true"]) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - freeCompilerArgs += '-Xallow-jvm-ir-dependencies' - - // Enable experimental coroutines APIs, including Flow - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' - freeCompilerArgs += '-Xopt-in=kotlin.Experimental' - - // Set JVM target to 1.8 - jvmTarget = "1.8" - } - } -} diff --git a/Jetsurvey/buildSrc/build.gradle.kts b/Jetsurvey/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Jetsurvey/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Jetsurvey/buildSrc/src/main/java/com/example/compose/jetsurvey/buildsrc/dependencies.kt b/Jetsurvey/buildSrc/src/main/java/com/example/compose/jetsurvey/buildsrc/dependencies.kt deleted file mode 100644 index d7a2a88eaf..0000000000 --- a/Jetsurvey/buildSrc/src/main/java/com/example/compose/jetsurvey/buildsrc/dependencies.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.buildsrc - -object Versions { - const val ktlint = "0.39.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha04" - const val jdkDesugar = "com.android.tools:desugar_jdk_libs:1.0.9" - - const val junit = "junit:junit:4.13" - - const val material = "com.google.android.material:material:1.1.0" - - object Accompanist { - private const val version = "0.4.2" - const val coil = "dev.chrisbanes.accompanist:accompanist-coil:$version" - } - - object Kotlin { - private const val version = "1.4.21" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.1" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object AndroidX { - const val appcompat = "androidx.appcompat:appcompat:1.2.0-rc01" - const val coreKtx = "androidx.core:core-ktx:1.5.0-alpha01" - - object Compose { - const val snapshot = "" - const val version = "1.0.0-alpha10" - - @get:JvmStatic - val snapshotUrl: String - get() = "https://androidx.dev/snapshots/builds/$snapshot/artifacts/repository/" - - const val foundation = "androidx.compose.foundation:foundation:$version" - const val layout = "androidx.compose.foundation:foundation-layout:$version" - const val material = "androidx.compose.material:material:$version" - const val materialIconsExtended = "androidx.compose.material:material-icons-extended:$version" - const val runtime = "androidx.compose.runtime:runtime:$version" - const val runtimeLivedata = "androidx.compose.runtime:runtime-livedata:$version" - const val tooling = "androidx.compose.ui:ui-tooling:$version" - const val test = "androidx.compose.test:test-core:$version" - const val uiTest = "androidx.compose.ui:ui-test:$version" - } - - object Navigation { - private const val version = "2.3.0" - const val fragment = "androidx.navigation:navigation-fragment-ktx:$version" - const val uiKtx = "androidx.navigation:navigation-ui-ktx:$version" - } - - object Material { - private const val version = "1.2.0" - const val material = "com.google.android.material:material:$version" - } - - object Test { - private const val version = "1.2.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - - object Lifecycle { - private const val version = "2.2.0" - const val extensions = "androidx.lifecycle:lifecycle-extensions:$version" - const val livedata = "androidx.lifecycle:lifecycle-livedata-ktx:$version" - const val viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - } - } -} diff --git a/Jetsurvey/debug.keystore b/Jetsurvey/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Jetsurvey/debug.keystore and /dev/null differ diff --git a/Jetsurvey/gradle.properties b/Jetsurvey/gradle.properties deleted file mode 100644 index b2d834ce9c..0000000000 --- a/Jetsurvey/gradle.properties +++ /dev/null @@ -1,42 +0,0 @@ -# -# Copyright 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m - -# Turn on parallel compilation, caching and on-demand configuration -org.gradle.configureondemand=true -org.gradle.caching=true -org.gradle.parallel=true - -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true - -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Jetsurvey/gradle/wrapper/gradle-wrapper.jar b/Jetsurvey/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index f6b961fd5a..0000000000 Binary files a/Jetsurvey/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/Jetsurvey/gradle/wrapper/gradle-wrapper.properties b/Jetsurvey/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index f3b943a378..0000000000 --- a/Jetsurvey/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Thu Jun 18 12:28:02 BST 2020 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-rc-1-bin.zip diff --git a/Jetsurvey/gradlew b/Jetsurvey/gradlew deleted file mode 100755 index cccdd3d517..0000000000 --- a/Jetsurvey/gradlew +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env sh - -############################################################################## -## -## 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="" - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; 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=$((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" - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - -exec "$JAVACMD" "$@" diff --git a/Jetsurvey/gradlew.bat b/Jetsurvey/gradlew.bat deleted file mode 100644 index e95643d6a2..0000000000 --- a/Jetsurvey/gradlew.bat +++ /dev/null @@ -1,84 +0,0 @@ -@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 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= - -@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 init - -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 init - -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 - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -: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 %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/Jetsurvey/screenshots/dark_signin.png b/Jetsurvey/screenshots/dark_signin.png deleted file mode 100644 index 7d3857712b..0000000000 Binary files a/Jetsurvey/screenshots/dark_signin.png and /dev/null differ diff --git a/Jetsurvey/screenshots/light_signin.png b/Jetsurvey/screenshots/light_signin.png deleted file mode 100644 index 60c4aad0ec..0000000000 Binary files a/Jetsurvey/screenshots/light_signin.png and /dev/null differ diff --git a/Jetsurvey/screenshots/signup_error.png b/Jetsurvey/screenshots/signup_error.png deleted file mode 100644 index 0b4133751e..0000000000 Binary files a/Jetsurvey/screenshots/signup_error.png and /dev/null differ diff --git a/Jetsurvey/screenshots/survey.gif b/Jetsurvey/screenshots/survey.gif deleted file mode 100644 index af2911ca97..0000000000 Binary files a/Jetsurvey/screenshots/survey.gif and /dev/null differ diff --git a/Jetsurvey/screenshots/welcome.png b/Jetsurvey/screenshots/welcome.png deleted file mode 100644 index f96aaf2f93..0000000000 Binary files a/Jetsurvey/screenshots/welcome.png and /dev/null differ diff --git a/Jetsurvey/settings.gradle b/Jetsurvey/settings.gradle deleted file mode 100644 index 347c7f3faa..0000000000 --- a/Jetsurvey/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -include ':app' -rootProject.name = "Jetsurvey" diff --git a/LICENSE b/LICENSE index 96198aca8f..79b3dd5a8e 100644 --- a/LICENSE +++ b/LICENSE @@ -174,7 +174,18 @@ END OF TERMS AND CONDITIONS - Copyright 2020 The Android Open Source Project + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -186,4 +197,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. + limitations under the License. \ No newline at end of file diff --git a/Owl/.gitignore b/Owl/.gitignore deleted file mode 100644 index 3d02999faf..0000000000 --- a/Owl/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -# Gradle -.gradle -build/ - -captures - -/local.properties - -# IntelliJ .idea folder -/.idea -*.iml - -# General -.DS_Store -.externalNativeBuild diff --git a/Owl/.google/packaging.yaml b/Owl/.google/packaging.yaml deleted file mode 100644 index 9d138a236b..0000000000 --- a/Owl/.google/packaging.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (C) 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# GOOGLE SAMPLE PACKAGING DATA -# -# This file is used by Google as part of our samples packaging process. -# End users may safely ignore this file. It has no relevance to other systems. ---- -status: PUBLISHED -technologies: [Android] -categories: [Compose] -languages: [Kotlin] -solutions: [Mobile] -github: android/compose-samples -level: INTERMEDIATE -apiRefs: - - android:androidx.compose.Composable -license: apache2 diff --git a/Owl/README.md b/Owl/README.md deleted file mode 100644 index 129fad0854..0000000000 --- a/Owl/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Owl sample - -This sample is a [Jetpack Compose][compose] implementation of [Owl][owl], a Material Design study. - -To try out these sample apps, you need to use the latest Canary version of Android Studio 4.2. -You can clone this repository or import the -project from Android Studio following the steps -[here](https://developer.android.com/jetpack/compose/setup#sample). - -This sample showcases: - -* [Material theming][materialtheming] & light/dark themes -* Custom layout -* Animation - -## Screenshots - -<img src="screenshots/owl.gif"/> - -## Features - -#### [Onboarding Screen](app/src/main/java/com/example/owl/ui/onboarding) -The onboarding screen allows users to customize their experience by selecting topics. Notable features: -* Custom [staggered grid layout](app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt#L239). -* [Topic chip](app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt#L171) with custom [selection animation](app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt#L157). - -#### [Courses Screen](app/src/main/java/com/example/owl/ui/courses) -The courses screen displays featured and saved course and a search screen. Notable fetures: -* Custom [`StaggeredVerticalGrid`](app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt#L161) responsive to available size. -* [`FeaturedCourse`](app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt#L70) composable demonstrates usage of [`ConstraintLayout`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/package-summary.html#ConstraintLayout(androidx.compose.ui.Modifier,%20kotlin.Function1)). - -#### [Course Details Screen](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt) -Displays details of a selected course, featuring: - -* A [FloatingActionButton](https://material.io/components/buttons-floating-action-button) that can be clicked or dragged to transform into a [`LessonsSheet`](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt#L309). -* A selection of [`RelatedCourses`](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt#L262) using a nested `BlueTheme`. - -#### [Theming](app/src/main/java/com/example/owl/ui/theme) -Owl follows Material Design, customizing [colors](app/src/main/java/com/example/owl/ui/theme/Color.kt), [typography](app/src/main/java/com/example/owl/ui/theme/Type.kt) and [shapes](app/src/main/java/com/example/owl/ui/theme/Shape.kt). These come together in Owl's multiple [themes](app/src/main/java/com/example/owl/ui/theme/Theme.kt), one for each color scheme. Additionaly, Owl supports [image](app/src/main/java/com/example/owl/ui/theme/Images.kt) and [elevation](app/src/main/java/com/example/owl/ui/theme/Elevation.kt) theming, providing alternate images/elevations in light/dark themes. - -#### [Common UI](app/src/main/java/com/example/owl/ui/common) -Compose makes it simple to create a library of components and use them throughout the app. See: -* [`CourseListItem`](app/src/main/java/com/example/owl/ui/common/CourseListItem.kt) is used on both the [My Courses](app/src/main/java/com/example/owl/ui/courses/MyCourses.kt) screen and in the related section of the [Course Details](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt) screen. -* [`OutlinedAvatar`](app/src/main/java/com/example/owl/ui/common/OutlinedAvatar.kt) is used on both the [Featured Courses](app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt) screen and the [Course Details](app/src/main/java/com/example/owl/ui/course/CourseDetails.kt) screen. - -#### [Utilities](app/src/main/java/com/example/owl/ui/utils/) -Owl implements some utility functions of interest: -* [Window insets](https://goo.gle/compose-insets) will likely be provided by the Compose library at some point. Until then this demonstrates how it can be implemented. -* [Navigation](app/src/main/java/com/example/owl/ui/utils/Navigation.kt): an implementation of [Android Architecture Components Navigation](https://developer.android.com/guide/navigation) will be provided for Compose at some point. Until then this class provides a simple [`Navigator`](app/src/main/java/com/example/owl/ui/utils/Navigation.kt#L32) with back-stack and a [`backHandler`](app/src/main/java/com/example/owl/ui/utils/Navigation.kt#L79) effect. - -## Data -Domain types are modelled in the [model package](app/src/main/java/com/example/owl/model), each containing static sample data exposed using fake `Repo`s objects. - -Imagery is sourced from [Unsplash](https://unsplash.com/) and [Pravatar](https://pravatar.cc/) and loaded using [coil-accompanist][coil-accompanist]. - - -## License -``` -Copyright 2020 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - https://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` - -[compose]: https://developer.android.com/jetpack/compose -[owl]: https://material.io/design/material-studies/owl.html -[materialtheming]: https://material.io/design/material-theming/overview.html#material-theming -[coil-accompanist]: https://github.com/chrisbanes/accompanist diff --git a/Owl/app/.gitignore b/Owl/app/.gitignore deleted file mode 100644 index 796b96d1c4..0000000000 --- a/Owl/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/Owl/app/build.gradle b/Owl/app/build.gradle deleted file mode 100644 index aaee72fb1e..0000000000 --- a/Owl/app/build.gradle +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.owl.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' -} - -android { - compileSdkVersion 30 - defaultConfig { - applicationId 'com.example.owl' - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName '1.0' - vectorDrawables.useSupportLibrary true - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - buildFeatures { - compose true - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerVersion Libs.Kotlin.version - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } - - packagingOptions { - exclude "META-INF/licenses/**" - exclude "META-INF/AL2.0" - exclude "META-INF/LGPL2.1" - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - implementation Libs.AndroidX.coreKtx - implementation Libs.AndroidX.navigation - - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.foundation - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.ui - implementation Libs.AndroidX.Compose.uiUtil - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.animation - implementation Libs.AndroidX.Compose.iconsExtended - implementation Libs.AndroidX.Compose.tooling - - implementation Libs.Accompanist.coil - implementation Libs.Accompanist.insets - - androidTestImplementation Libs.junit - androidTestImplementation Libs.AndroidX.Test.core - androidTestImplementation Libs.AndroidX.Test.espressoCore - androidTestImplementation Libs.AndroidX.Test.rules - androidTestImplementation Libs.AndroidX.Test.Ext.junit - androidTestImplementation Libs.AndroidX.Compose.uiTest -} diff --git a/Owl/app/proguard-rules.pro b/Owl/app/proguard-rules.pro deleted file mode 100644 index 4cb94585a0..0000000000 --- a/Owl/app/proguard-rules.pro +++ /dev/null @@ -1,24 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile - -# Repackage classes into the top-level. --repackageclasses diff --git a/Owl/app/src/androidTest/java/com/example/owl/ui/NavigationTest.kt b/Owl/app/src/androidTest/java/com/example/owl/ui/NavigationTest.kt deleted file mode 100644 index 48bef31b83..0000000000 --- a/Owl/app/src/androidTest/java/com/example/owl/ui/NavigationTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui - -import androidx.activity.ComponentActivity -import androidx.compose.runtime.Providers -import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.hasSubstring -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithSubstring -import androidx.compose.ui.test.performClick -import androidx.test.platform.app.InstrumentationRegistry -import com.example.owl.R -import com.example.owl.model.courses -import com.example.owl.ui.fakes.ProvideTestImageLoader -import com.example.owl.ui.utils.AmbientBackDispatcher -import dev.chrisbanes.accompanist.insets.ProvideWindowInsets -import org.junit.Rule -import org.junit.Test - -/** - * Checks that the navigation flows in the app are correct. - */ -class NavigationTest { - - /** - * Using an empty activity to have control of the content that is set. - * - * This activity must be declared in the manifest (see src/debug/AndroidManifest.xml) - */ - @get:Rule - val composeTestRule = createAndroidComposeRule<ComponentActivity>() - lateinit var activity: ComponentActivity - - private fun startActivity(startDestination: String? = null) { - composeTestRule.activityRule.scenario.onActivity { - activity = it - composeTestRule.setContent { - Providers(AmbientBackDispatcher provides activity.onBackPressedDispatcher) { - ProvideWindowInsets { - ProvideTestImageLoader { - if (startDestination == null) { - NavGraph() - } else { - NavGraph(startDestination) - } - } - } - } - } - } - } - - @Test - fun firstScreenIsOnboarding() { - // When the app is open - startActivity() - // The first screen should be the onboarding screen. - // Assert that the FAB label for the onboarding screen exists: - composeTestRule.onNodeWithContentDescription(getOnboardingFabLabel()).assertExists() - } - - @Test - fun onboardingToCourses() { - // Given the app in the onboarding screen - startActivity() - - // Navigate to the next screen by clicking on the FAB - val fabLabel = getOnboardingFabLabel() - composeTestRule.onNodeWithContentDescription(fabLabel).performClick() - - // The first course should be shown - composeTestRule.onNodeWithSubstring(courses.first().name).assertExists() - } - - @Test - fun coursesToDetail() { - // Given the app in the courses screen - startActivity(MainDestinations.COURSES_ROUTE) - - // Navigate to the first course - composeTestRule.onNode( - hasContentDescription(getFeaturedCourseLabel()).and(hasSubstring(courses.first().name)) - ).performClick() - - // Assert navigated to the course details - composeTestRule.onNodeWithSubstring(getCourseDesc().take(15)).assertExists() - } - - @Test - fun coursesToDetailAndBack() { - coursesToDetail() - composeTestRule.runOnUiThread { - activity.onBackPressed() - } - - // The first course should be shown - composeTestRule.onNodeWithSubstring(courses.first().name).assertExists() - } - - private fun getOnboardingFabLabel(): String { - return InstrumentationRegistry.getInstrumentation().targetContext.resources - .getString(R.string.continue_to_courses) - } - - private fun getFeaturedCourseLabel(): String { - return InstrumentationRegistry.getInstrumentation().targetContext.resources - .getString(R.string.featured) - } - - private fun getCourseDesc(): String { - return InstrumentationRegistry.getInstrumentation().targetContext.resources - .getString(R.string.course_desc) - } -} diff --git a/Owl/app/src/androidTest/java/com/example/owl/ui/fakes/ProvideTestImageLoader.kt b/Owl/app/src/androidTest/java/com/example/owl/ui/fakes/ProvideTestImageLoader.kt deleted file mode 100644 index 073837e54f..0000000000 --- a/Owl/app/src/androidTest/java/com/example/owl/ui/fakes/ProvideTestImageLoader.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.fakes - -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import coil.ImageLoader -import coil.annotation.ExperimentalCoilApi -import coil.bitmap.BitmapPool -import coil.decode.DataSource -import coil.memory.MemoryCache -import coil.request.DefaultRequestOptions -import coil.request.Disposable -import coil.request.ImageRequest -import coil.request.ImageResult -import coil.request.SuccessResult -import dev.chrisbanes.accompanist.coil.AmbientImageLoader - -/** - * Replaces all remote images with a simple black drawable to make testing faster and hermetic. - */ -@OptIn(ExperimentalCoilApi::class) -@Composable -fun ProvideTestImageLoader(content: @Composable () -> Unit) { - - // From https://coil-kt.github.io/coil/image_loaders/ - val loader = object : ImageLoader { - private val drawable = ColorDrawable(Color.BLACK) - - private val disposable = object : Disposable { - override val isDisposed get() = true - override fun dispose() {} - override suspend fun await() {} - } - - override val bitmapPool: BitmapPool = BitmapPool(0) - - override val defaults: DefaultRequestOptions = DefaultRequestOptions() - override val memoryCache: MemoryCache - get() = TODO("Not yet implemented") - - override fun enqueue(request: ImageRequest): Disposable { - // Always call onStart before onSuccess. - request.target?.onStart(drawable) - request.target?.onSuccess(drawable) - return disposable - } - - override suspend fun execute(request: ImageRequest): ImageResult { - return SuccessResult( - drawable = drawable, - request = request, - metadata = ImageResult.Metadata( - memoryCacheKey = MemoryCache.Key(""), - isSampled = false, - dataSource = DataSource.MEMORY_CACHE, - isPlaceholderMemoryCacheKeyPresent = false - ) - ) - } - - override fun shutdown() { } - } - Providers(AmbientImageLoader provides loader, content = content) -} diff --git a/Owl/app/src/debug/AndroidManifest.xml b/Owl/app/src/debug/AndroidManifest.xml deleted file mode 100644 index aa21dd579b..0000000000 --- a/Owl/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,19 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<manifest xmlns:android="http://schemas.android.com/apk/res/android"> - <application> - <activity android:name="androidx.activity.ComponentActivity" /> - </application> -</manifest> \ No newline at end of file diff --git a/Owl/app/src/main/AndroidManifest.xml b/Owl/app/src/main/AndroidManifest.xml deleted file mode 100644 index 855b3b5422..0000000000 --- a/Owl/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,37 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> - -<manifest - xmlns:android="http://schemas.android.com/apk/res/android" - package="com.example.owl"> - - <!--Load images from Unsplash--> - <uses-permission android:name="android.permission.INTERNET" /> - - <application - android:allowBackup="true" - android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" - android:supportsRtl="true" - android:theme="@style/Theme.Owl"> - <activity android:name=".ui.MainActivity"> - <intent-filter> - <action android:name="android.intent.action.MAIN" /> - <category android:name="android.intent.category.LAUNCHER" /> - </intent-filter> - </activity> - </application> - -</manifest> diff --git a/Owl/app/src/main/java/com/example/owl/model/Course.kt b/Owl/app/src/main/java/com/example/owl/model/Course.kt deleted file mode 100644 index 85f3dc498f..0000000000 --- a/Owl/app/src/main/java/com/example/owl/model/Course.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.model - -import androidx.compose.runtime.Immutable - -@Immutable // Tell Compose runtime that this object will not change so it can perform optimizations -data class Course( - val id: Long, - val name: String, - val subject: String, - val thumbUrl: String, - val thumbContentDesc: String, - val description: String = "", - val steps: Int, - val step: Int, - val instructor: String = "https://i.pravatar.cc/112?$id" -) - -/** - * A fake repo - */ -object CourseRepo { - fun getCourse(courseId: Long): Course = courses.find { it.id == courseId }!! - fun getRelated(@Suppress("UNUSED_PARAMETER") courseId: Long): List<Course> = courses -} - -val courses = listOf( - Course( - id = 0, - name = "Basic Blocks and Woodturning", - subject = "Arts & Crafts", - thumbUrl = "https://images.unsplash.com/photo-1516562309708-05f3b2b2c238", - thumbContentDesc = "", - steps = 7, - step = 1 - ), - Course( - id = 1, - name = "An Introduction To Oil Painting On Canvas", - subject = "Painting", - thumbUrl = "https://images.unsplash.com/photo-1508261301902-79a2d8e78f71", - thumbContentDesc = "", - steps = 12, - step = 1 - ), - Course( - id = 2, - name = "Understanding the Composition of Modern Cities", - subject = "Architecture", - thumbUrl = "https://images.unsplash.com/photo-1519999482648-25049ddd37b1", - thumbContentDesc = "", - steps = 18, - step = 1 - ), - Course( - id = 3, - name = "Learning The Basics of Brand Identity", - subject = "Design", - thumbUrl = "https://images.unsplash.com/photo-1517602302552-471fe67acf66", - thumbContentDesc = "", - steps = 22, - step = 1 - ), - Course( - id = 4, - name = "Wooden Materials and Sculpting Machinery", - subject = "Arts & Crafts", - thumbUrl = "https://images.unsplash.com/photo-1547609434-b732edfee020", - thumbContentDesc = "", - steps = 19, - step = 1 - ), - Course( - id = 5, - name = "Advanced Potter's Wheel", - subject = "Arts & Crafts", - thumbUrl = "https://images.unsplash.com/photo-1513096082106-f68f05c8c21c", - thumbContentDesc = "", - steps = 14, - step = 1 - ), - Course( - id = 6, - name = "Advanced Abstract Shapes & 3D Printing", - subject = "Arts & Crafts", - thumbUrl = "https://images.unsplash.com/photo-1461887046916-c7426e65460d", - thumbContentDesc = "", - steps = 17, - step = 1 - ), - Course( - id = 7, - name = "Beginning Portraiture", - subject = "Photography", - thumbUrl = "https://images.unsplash.com/photo-1555940451-2480c214446f", - thumbContentDesc = "", - steps = 22, - step = 1 - ), - Course( - id = 8, - name = "Intermediate Knife Skills", - subject = "Culinary", - thumbUrl = "https://images.unsplash.com/photo-1544965838-54ef8406f868", - thumbContentDesc = "", - steps = 14, - step = 1 - ), - Course( - id = 9, - name = "Pattern Making for Beginners", - subject = "Fashion", - thumbUrl = "https://images.unsplash.com/photo-1552737894-aae873ee2737", - thumbContentDesc = "", - steps = 7, - step = 1 - ), - Course( - id = 10, - name = "Location Lighting for Beginners", - subject = "Photography", - thumbUrl = "https://images.unsplash.com/photo-1554941829-202a0b2403b8", - thumbContentDesc = "", - steps = 6, - step = 1 - ), - Course( - id = 11, - name = "Cinematography & Lighting", - subject = "Film", - thumbUrl = "https://images.unsplash.com/photo-1517523267857-911eef21acae", - thumbContentDesc = "", - steps = 4, - step = 1 - ), - Course( - id = 12, - name = "Monuments, Buildings & Other Structures", - subject = "Photography", - thumbUrl = "https://images.unsplash.com/photo-1494145904049-0dca59b4bbad", - thumbContentDesc = "", - steps = 4, - step = 1 - ) -) diff --git a/Owl/app/src/main/java/com/example/owl/model/Lesson.kt b/Owl/app/src/main/java/com/example/owl/model/Lesson.kt deleted file mode 100644 index 8bba647582..0000000000 --- a/Owl/app/src/main/java/com/example/owl/model/Lesson.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.model - -import androidx.compose.runtime.Immutable - -@Immutable -data class Lesson( - val title: String, - val formattedStepNumber: String, - val length: String, - val imageUrl: String, - val imageContentDescription: String = "" -) - -/** - * A fake repo - */ -object LessonsRepo { - fun getLessons(@Suppress("UNUSED_PARAMETER") courseId: Long) = lessons -} - -val lessons = listOf( - Lesson( - title = "An introduction to the Landscape", - formattedStepNumber = "01", - length = "4:14", - imageUrl = "https://images.unsplash.com/photo-1506744038136-46273834b3fb" - ), - Lesson( - title = "Movement and Expression", - formattedStepNumber = "02", - length = "7:28", - imageUrl = "https://images.unsplash.com/photo-1511715282680-fbf93a50e721" - ), - Lesson( - title = "Composition and the Urban Canvas", - formattedStepNumber = "03", - length = "3:43", - imageUrl = "https://images.unsplash.com/photo-1494616150024-f6040d5220c0" - ), - Lesson( - title = "Lighting Techniques and Aesthetics", - formattedStepNumber = "04", - length = "4:45", - imageUrl = "https://images.unsplash.com/photo-1544980944-0bf2ec0063ef" - ), - Lesson( - title = "Special Effects", - formattedStepNumber = "05", - length = "6:19", - imageUrl = "https://images.unsplash.com/photo-1508521049563-61d4bb00b270" - ), - Lesson( - title = "Techniques with Structures", - formattedStepNumber = "06", - length = "9:41", - imageUrl = "https://images.unsplash.com/photo-1479839672679-a46483c0e7c8" - ), - Lesson( - title = "Deep Focus Using a Camera Dolly", - formattedStepNumber = "07", - length = "4:43", - imageUrl = "https://images.unsplash.com/photo-1495854245347-f3936493f799" - ), - Lesson( - title = "Point of View Shots with Structures", - formattedStepNumber = "08", - length = "9:41", - imageUrl = "https://images.unsplash.com/photo-1534971710649-2f97e5f98bc4" - ), - Lesson( - title = "Photojournalism: Street Art", - formattedStepNumber = "09", - length = "9:41", - imageUrl = "https://images.unsplash.com/photo-1453814235491-3cfac3999928" - ) -) diff --git a/Owl/app/src/main/java/com/example/owl/model/Topic.kt b/Owl/app/src/main/java/com/example/owl/model/Topic.kt deleted file mode 100644 index 7cd0f0f94e..0000000000 --- a/Owl/app/src/main/java/com/example/owl/model/Topic.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.model - -import androidx.compose.runtime.Immutable - -@Immutable -data class Topic( - val name: String, - val courses: Int, - val imageUrl: String -) - -val topics = listOf( - Topic("Architecture", 58, "https://images.unsplash.com/photo-1479839672679-a46483c0e7c8"), - Topic("Arts & Crafts", 121, "https://images.unsplash.com/photo-1422246358533-95dcd3d48961"), - Topic("Business", 78, "https://images.unsplash.com/photo-1507679799987-c73779587ccf"), - Topic("Culinary", 118, "https://images.unsplash.com/photo-1551218808-94e220e084d2"), - Topic("Design", 423, "https://images.unsplash.com/photo-1493932484895-752d1471eab5"), - Topic("Fashion", 92, "https://images.unsplash.com/photo-1517840545241-b491010a8af4"), - Topic("Film", 165, "https://images.unsplash.com/photo-1518676590629-3dcbd9c5a5c9"), - Topic("Gaming", 164, "https://images.unsplash.com/photo-1528870884180-5649b20f6435"), - Topic("Illustration", 326, "https://images.unsplash.com/photo-1526312426976-f4d754fa9bd6"), - Topic("Lifestyle", 305, "https://images.unsplash.com/photo-1471560090527-d1af5e4e6eb6"), - Topic("Music", 212, "https://images.unsplash.com/photo-1454922915609-78549ad709bb"), - Topic("Painting", 172, "https://images.unsplash.com/photo-1461344577544-4e5dc9487184"), - Topic("Photography", 321, "https://images.unsplash.com/photo-1542567455-cd733f23fbb1"), - Topic("Technology", 118, "https://images.unsplash.com/photo-1535223289827-42f1e9919769") -) diff --git a/Owl/app/src/main/java/com/example/owl/ui/MainActivity.kt b/Owl/app/src/main/java/com/example/owl/ui/MainActivity.kt deleted file mode 100644 index dce8340cff..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/MainActivity.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.compose.ui.platform.setContent -import androidx.core.view.WindowCompat - -class MainActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // This app draws behind the system bars, so we want to handle fitting system windows - WindowCompat.setDecorFitsSystemWindows(window, false) - - setContent { - OwlApp(onBackPressedDispatcher) - } - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/NavGraph.kt b/Owl/app/src/main/java/com/example/owl/ui/NavGraph.kt deleted file mode 100644 index 04038c3f4f..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/NavGraph.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.navArgument -import androidx.navigation.compose.navigate -import androidx.navigation.compose.rememberNavController -import com.example.owl.ui.MainDestinations.COURSE_DETAIL_ID_KEY -import com.example.owl.ui.course.CourseDetails -import com.example.owl.ui.courses.Courses -import com.example.owl.ui.onboarding.Onboarding - -/** - * Destinations used in the ([OwlApp]). - */ -object MainDestinations { - const val ONBOARDING_ROUTE = "onboarding" - const val COURSES_ROUTE = "courses" - const val COURSE_DETAIL_ROUTE = "course" - const val COURSE_DETAIL_ID_KEY = "courseId" -} - -@Composable -fun NavGraph(startDestination: String = MainDestinations.ONBOARDING_ROUTE) { - val navController = rememberNavController() - - val actions = remember(navController) { MainActions(navController) } - NavHost( - navController = navController, - startDestination = startDestination - ) { - composable(MainDestinations.ONBOARDING_ROUTE) { - Onboarding(onboardingComplete = actions.onboardingComplete) - } - composable(MainDestinations.COURSES_ROUTE) { - Courses(selectCourse = actions.selectCourse) - } - composable( - "${MainDestinations.COURSE_DETAIL_ROUTE}/{$COURSE_DETAIL_ID_KEY}", - arguments = listOf(navArgument(COURSE_DETAIL_ID_KEY) { type = NavType.LongType }) - ) { backStackEntry -> - val arguments = requireNotNull(backStackEntry.arguments) - CourseDetails( - courseId = arguments.getLong(COURSE_DETAIL_ID_KEY), - selectCourse = actions.selectCourse, - upPress = actions.upPress - ) - } - } -} - -/** - * Models the navigation actions in the app. - */ -class MainActions(navController: NavHostController) { - val onboardingComplete: () -> Unit = { - navController.navigate(MainDestinations.COURSES_ROUTE) - } - val selectCourse: (Long) -> Unit = { courseId: Long -> - navController.navigate("${MainDestinations.COURSE_DETAIL_ROUTE}/$courseId") - } - val upPress: () -> Unit = { - navController.navigateUp() - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/OwlApp.kt b/Owl/app/src/main/java/com/example/owl/ui/OwlApp.kt deleted file mode 100644 index 11285dde1a..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/OwlApp.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui - -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import com.example.owl.ui.utils.AmbientBackDispatcher -import com.example.owl.ui.utils.ProvideImageLoader -import dev.chrisbanes.accompanist.insets.ProvideWindowInsets - -@Composable -fun OwlApp(backDispatcher: OnBackPressedDispatcher) { - - Providers(AmbientBackDispatcher provides backDispatcher) { - ProvideWindowInsets { - ProvideImageLoader { - NavGraph() - } - } - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/common/CourseListItem.kt b/Owl/app/src/main/java/com/example/owl/ui/common/CourseListItem.kt deleted file mode 100644 index 0e3792b055..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/common/CourseListItem.kt +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.common - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.OndemandVideo -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.Course -import com.example.owl.model.courses -import com.example.owl.ui.theme.BlueTheme -import com.example.owl.ui.theme.OwlTheme -import com.example.owl.ui.utils.NetworkImage - -@Composable -fun CourseListItem( - course: Course, - onClick: () -> Unit, - modifier: Modifier = Modifier, - shape: Shape = RectangleShape, - elevation: Dp = OwlTheme.elevations.card, - titleStyle: TextStyle = MaterialTheme.typography.subtitle1, - iconSize: Dp = 16.dp -) { - Surface( - elevation = elevation, - shape = shape, - modifier = modifier - ) { - Row(modifier = Modifier.clickable(onClick = onClick)) { - NetworkImage( - url = course.thumbUrl, - modifier = Modifier.aspectRatio(1f) - ) - Column( - modifier = Modifier.padding( - start = 16.dp, - top = 16.dp, - end = 16.dp, - bottom = 8.dp - ) - ) { - Text( - text = course.name, - style = titleStyle, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - .padding(bottom = 4.dp) - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Rounded.OndemandVideo, - tint = MaterialTheme.colors.primary, - modifier = Modifier.preferredSize(iconSize) - ) - Text( - text = stringResource( - R.string.course_step_steps, - course.step, - course.steps - ), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.caption, - modifier = Modifier - .padding(start = 8.dp) - .weight(1f) - .wrapContentWidth(Alignment.Start) - ) - NetworkImage( - url = course.instructor, - modifier = Modifier - .preferredSize(28.dp) - .clip(CircleShape) - ) - } - } - } - } -} - -@Preview(name = "Course list item") -@Composable -private fun CourseListItemPreviewLight() { - CourseListItemPreview(false) -} - -@Preview(name = "Course list item – Dark") -@Composable -private fun CourseListItemPreviewDark() { - CourseListItemPreview(true) -} - -@Composable -private fun CourseListItemPreview(darkTheme: Boolean) { - BlueTheme(darkTheme) { - CourseListItem( - course = courses.first(), - onClick = {} - ) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/common/OutlinedAvatar.kt b/Owl/app/src/main/java/com/example/owl/ui/common/OutlinedAvatar.kt deleted file mode 100644 index f82067ba64..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/common/OutlinedAvatar.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.common - -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.owl.ui.theme.BlueTheme -import com.example.owl.ui.utils.NetworkImage - -@Composable -fun OutlinedAvatar( - url: String, - modifier: Modifier = Modifier, - outlineSize: Dp = 3.dp, - outlineColor: Color = MaterialTheme.colors.surface -) { - Box( - modifier = modifier.background( - color = outlineColor, - shape = CircleShape - ) - ) { - NetworkImage( - url = url, - modifier = Modifier - .padding(outlineSize) - .fillMaxSize() - .clip(CircleShape) - ) - } -} - -@Preview( - name = "Outlined Avatar", - widthDp = 40, - heightDp = 40 -) -@Composable -private fun OutlinedAvatarPreview() { - BlueTheme { - OutlinedAvatar(url = "") - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/course/CourseDetails.kt b/Owl/app/src/main/java/com/example/owl/ui/course/CourseDetails.kt deleted file mode 100644 index ec5549eb63..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/course/CourseDetails.kt +++ /dev/null @@ -1,540 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.course - -import androidx.compose.animation.core.animateAsState -import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FractionalThreshold -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.contentColorFor -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.material.icons.rounded.PlayCircleOutline -import androidx.compose.material.icons.rounded.PlaylistPlay -import androidx.compose.material.primarySurface -import androidx.compose.material.rememberSwipeableState -import androidx.compose.material.swipeable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.gesture.scrollorientationlocking.Orientation -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.WithConstraints -import androidx.compose.ui.platform.AmbientDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.Course -import com.example.owl.model.CourseRepo -import com.example.owl.model.Lesson -import com.example.owl.model.LessonsRepo -import com.example.owl.model.courses -import com.example.owl.ui.common.CourseListItem -import com.example.owl.ui.common.OutlinedAvatar -import com.example.owl.ui.theme.BlueTheme -import com.example.owl.ui.theme.PinkTheme -import com.example.owl.ui.theme.pink500 -import com.example.owl.ui.utils.NetworkImage -import com.example.owl.ui.utils.backHandler -import com.example.owl.ui.utils.lerp -import com.example.owl.ui.utils.scrim -import dev.chrisbanes.accompanist.insets.AmbientWindowInsets -import dev.chrisbanes.accompanist.insets.navigationBarsPadding -import dev.chrisbanes.accompanist.insets.statusBarsPadding -import dev.chrisbanes.accompanist.insets.toPaddingValues - -private val FabSize = 56.dp -private const val ExpandedSheetAlpha = 0.96f - -@Composable -fun CourseDetails( - courseId: Long, - selectCourse: (Long) -> Unit, - upPress: () -> Unit -) { - // Simplified for the sample - val course = remember(courseId) { CourseRepo.getCourse(courseId) } - // TODO: Show error if course not found. - CourseDetails(course, selectCourse, upPress) -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun CourseDetails( - course: Course, - selectCourse: (Long) -> Unit, - upPress: () -> Unit -) { - PinkTheme { - WithConstraints { - val sheetState = rememberSwipeableState(SheetState.Closed) - val fabSize = with(AmbientDensity.current) { FabSize.toPx() } - val dragRange = constraints.maxHeight - fabSize - - backHandler( - enabled = sheetState.value == SheetState.Open, - onBack = { sheetState.animateTo(SheetState.Closed) } - ) - - Box( - // The Lessons sheet is initially closed and appears as a FAB. Make it openable by - // swiping or clicking the FAB. - Modifier.swipeable( - state = sheetState, - anchors = mapOf( - 0f to SheetState.Closed, - -dragRange to SheetState.Open - ), - thresholds = { _, _ -> FractionalThreshold(0.5f) }, - orientation = Orientation.Vertical - ) - ) { - val openFraction = if (sheetState.offset.value.isNaN()) { - 0f - } else { - -sheetState.offset.value / dragRange - }.coerceIn(0f, 1f) - CourseDescription(course, selectCourse, upPress) - LessonsSheet( - course, - openFraction, - constraints.maxWidth.toFloat(), - constraints.maxHeight.toFloat() - ) { state -> - sheetState.animateTo(state) - } - } - } - } -} - -@Composable -private fun CourseDescription( - course: Course, - selectCourse: (Long) -> Unit, - upPress: () -> Unit -) { - Surface(modifier = Modifier.fillMaxSize()) { - ScrollableColumn { - CourseDescriptionHeader(course, upPress) - CourseDescriptionBody(course) - RelatedCourses(course.id, selectCourse) - } - } -} - -@Composable -private fun CourseDescriptionHeader( - course: Course, - upPress: () -> Unit -) { - Box { - NetworkImage( - url = course.thumbUrl, - modifier = Modifier - .fillMaxWidth() - .scrim(colors = listOf(Color(0x80000000), Color(0x33000000))) - .aspectRatio(4f / 3f) - ) - TopAppBar( - backgroundColor = Color.Transparent, - elevation = 0.dp, - contentColor = Color.White, // always white as image has dark scrim - modifier = Modifier.statusBarsPadding() - ) { - IconButton(onClick = upPress) { - Icon( - imageVector = Icons.Rounded.ArrowBack - ) - } - Image( - imageVector = vectorResource(id = R.drawable.ic_logo), - modifier = Modifier - .padding(bottom = 4.dp) - .preferredSize(24.dp) - .align(Alignment.CenterVertically) - ) - Spacer(modifier = Modifier.weight(1f)) - } - OutlinedAvatar( - url = course.instructor, - modifier = Modifier - .preferredSize(40.dp) - .align(Alignment.BottomCenter) - .offset(y = 20.dp) // overlap bottom of image - ) - } -} - -@Composable -private fun CourseDescriptionBody(course: Course) { - Text( - text = course.subject.toUpperCase(), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.body2, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding( - start = 16.dp, - top = 36.dp, - end = 16.dp, - bottom = 16.dp - ) - ) - Text( - text = course.name, - style = MaterialTheme.typography.h4, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.preferredHeight(16.dp)) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.course_desc), - style = MaterialTheme.typography.body1, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) - } - Divider(modifier = Modifier.padding(16.dp)) - Text( - text = stringResource(id = R.string.what_you_ll_need), - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text( - text = stringResource(id = R.string.needs), - style = MaterialTheme.typography.body1, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding( - start = 16.dp, - top = 16.dp, - end = 16.dp, - bottom = 32.dp - ) - ) - } -} - -@Composable -private fun RelatedCourses( - courseId: Long, - selectCourse: (Long) -> Unit -) { - val relatedCourses = remember(courseId) { CourseRepo.getRelated(courseId) } - BlueTheme { - Surface( - color = MaterialTheme.colors.primarySurface, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.navigationBarsPadding()) { - Text( - text = stringResource(id = R.string.you_ll_also_like), - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 16.dp, - vertical = 24.dp - ) - ) - LazyRow( - contentPadding = PaddingValues( - start = 16.dp, - bottom = 32.dp, - end = FabSize + 8.dp - ) - ) { - items(relatedCourses) { related -> - CourseListItem( - course = related, - onClick = { selectCourse(related.id) }, - titleStyle = MaterialTheme.typography.body2, - modifier = Modifier - .padding(end = 8.dp) - .preferredSize(288.dp, 80.dp), - iconSize = 14.dp - ) - } - } - } - } - } -} - -@Composable -private fun LessonsSheet( - course: Course, - openFraction: Float, - width: Float, - height: Float, - updateSheet: (SheetState) -> Unit -) { - // Use the fraction that the sheet is open to drive the transformation from FAB -> Sheet - val fabSize = with(AmbientDensity.current) { FabSize.toPx() } - val fabSheetHeight = fabSize + AmbientWindowInsets.current.systemBars.bottom - val offsetX = lerp(width - fabSize, 0f, 0f, 0.15f, openFraction) - val offsetY = lerp(height - fabSheetHeight, 0f, openFraction) - val tlCorner = lerp(fabSize, 0f, 0f, 0.15f, openFraction) - val surfaceColor = lerp( - startColor = pink500, - endColor = MaterialTheme.colors.primarySurface.copy(alpha = ExpandedSheetAlpha), - startFraction = 0f, - endFraction = 0.3f, - fraction = openFraction - ) - Surface( - color = surfaceColor, - contentColor = contentColorFor(color = MaterialTheme.colors.primarySurface), - shape = RoundedCornerShape(topLeft = tlCorner), - modifier = Modifier.graphicsLayer { - translationX = offsetX - translationY = offsetY - } - ) { - Lessons(course, openFraction, surfaceColor, updateSheet) - } -} - -@Composable -private fun Lessons( - course: Course, - openFraction: Float, - surfaceColor: Color = MaterialTheme.colors.surface, - updateSheet: (SheetState) -> Unit -) { - val lessons: List<Lesson> = remember(course.id) { LessonsRepo.getLessons(course.id) } - - Box(modifier = Modifier.fillMaxWidth()) { - // When sheet open, show a list of the lessons - val lessonsAlpha = lerp(0f, 1f, 0.2f, 0.8f, openFraction) - Column( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = lessonsAlpha } - .statusBarsPadding() - ) { - val scroll = rememberScrollState() - val appBarElevation by animateAsState(if (scroll.value > 0f) 4.dp else 0.dp) - val appBarColor = if (appBarElevation > 0.dp) surfaceColor else Color.Transparent - TopAppBar( - backgroundColor = appBarColor, - elevation = appBarElevation - ) { - Text( - text = course.name, - style = MaterialTheme.typography.subtitle1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(16.dp) - .weight(1f) - .align(Alignment.CenterVertically) - ) - IconButton( - onClick = { updateSheet(SheetState.Closed) }, - modifier = Modifier.align(Alignment.CenterVertically) - ) { - Icon(imageVector = Icons.Rounded.ExpandMore) - } - } - ScrollableColumn( - scrollState = scroll, - contentPadding = AmbientWindowInsets.current.systemBars.toPaddingValues( - top = false - ) - ) { - lessons.forEach { lesson -> - Lesson(lesson) - Divider(startIndent = 128.dp) - } - } - } - - // When sheet closed, show the FAB - val fabAlpha = lerp(1f, 0f, 0f, 0.15f, openFraction) - Box( - modifier = Modifier - .preferredSize(FabSize) - .padding(start = 16.dp, top = 8.dp) // visually center contents - .graphicsLayer { alpha = fabAlpha } - ) { - IconButton( - modifier = Modifier.align(Alignment.Center), - onClick = { updateSheet(SheetState.Open) } - ) { - Icon( - imageVector = Icons.Rounded.PlaylistPlay, - tint = MaterialTheme.colors.onPrimary - ) - } - } - } -} - -@Composable -private fun Lesson(lesson: Lesson) { - Row( - modifier = Modifier - .clickable(onClick = { /* todo */ }) - .padding(vertical = 16.dp) - ) { - NetworkImage( - url = lesson.imageUrl, - modifier = Modifier.preferredSize(112.dp, 64.dp) - ) - Column( - modifier = Modifier - .weight(1f) - .padding(start = 16.dp) - ) { - Text( - text = lesson.title, - style = MaterialTheme.typography.subtitle2, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Row( - modifier = Modifier.padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.PlayCircleOutline, - modifier = Modifier.preferredSize(16.dp) - ) - Text( - modifier = Modifier.padding(start = 4.dp), - text = lesson.length, - style = MaterialTheme.typography.caption - ) - } - } - } - Text( - text = lesson.formattedStepNumber, - style = MaterialTheme.typography.subtitle2, - modifier = Modifier.padding(horizontal = 16.dp) - ) - } -} - -private enum class SheetState { Open, Closed } - -@Preview(name = "Course Details") -@Composable -private fun CourseDetailsPreview() { - val courseId = courses.first().id - CourseDetails( - courseId = courseId, - selectCourse = { }, - upPress = { } - ) -} - -@Preview(name = "Lessons Sheet — Closed") -@Composable -private fun LessonsSheetClosedPreview() { - LessonsSheetPreview(0f) -} - -@Preview(name = "Lessons Sheet — Open") -@Composable -private fun LessonsSheetOpenPreview() { - LessonsSheetPreview(1f) -} - -@Preview(name = "Lessons Sheet — Open – Dark") -@Composable -private fun LessonsSheetOpenDarkPreview() { - LessonsSheetPreview(1f, true) -} - -@Composable -private fun LessonsSheetPreview( - openFraction: Float, - darkTheme: Boolean = false -) { - PinkTheme(darkTheme) { - val color = MaterialTheme.colors.primarySurface - Surface(color = color) { - Lessons( - course = courses.first(), - openFraction = openFraction, - surfaceColor = color, - updateSheet = { } - ) - } - } -} - -@Preview(name = "Related") -@Composable -private fun RelatedCoursesPreview() { - val related = courses.random() - RelatedCourses( - courseId = related.id, - selectCourse = { } - ) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/courses/Courses.kt b/Owl/app/src/main/java/com/example/owl/ui/courses/Courses.kt deleted file mode 100644 index 838d92a01c..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/courses/Courses.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.courses - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.BottomNavigation -import androidx.compose.material.BottomNavigationItem -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.primarySurface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.courses -import com.example.owl.model.topics -import com.example.owl.ui.theme.BlueTheme -import dev.chrisbanes.accompanist.insets.navigationBarsHeight -import dev.chrisbanes.accompanist.insets.navigationBarsPadding - -@Composable -fun Courses(selectCourse: (Long) -> Unit) { - BlueTheme { - val (selectedTab, setSelectedTab) = remember { mutableStateOf(CourseTabs.FEATURED) } - val tabs = CourseTabs.values() - Scaffold( - backgroundColor = MaterialTheme.colors.primarySurface, - bottomBar = { - BottomNavigation( - Modifier.navigationBarsHeight(additional = 56.dp) - ) { - tabs.forEach { tab -> - BottomNavigationItem( - icon = { Icon(vectorResource(tab.icon)) }, - label = { Text(stringResource(tab.title).toUpperCase()) }, - selected = tab == selectedTab, - onClick = { setSelectedTab(tab) }, - alwaysShowLabels = false, - selectedContentColor = MaterialTheme.colors.secondary, - unselectedContentColor = AmbientContentColor.current, - modifier = Modifier.navigationBarsPadding() - ) - } - } - } - ) { innerPadding -> - val modifier = Modifier.padding(innerPadding) - when (selectedTab) { - CourseTabs.MY_COURSES -> MyCourses(courses, selectCourse, modifier) - CourseTabs.FEATURED -> FeaturedCourses(courses, selectCourse, modifier) - CourseTabs.SEARCH -> SearchCourses(topics, modifier) - } - } - } -} - -@Composable -fun CoursesAppBar() { - TopAppBar( - elevation = 0.dp, - modifier = Modifier.preferredHeight(80.dp) - ) { - Image( - modifier = Modifier - .padding(16.dp) - .align(Alignment.CenterVertically), - imageVector = vectorResource(id = R.drawable.ic_lockup_white) - ) - IconButton( - modifier = Modifier.align(Alignment.CenterVertically), - onClick = { /* todo */ } - ) { - Icon(Icons.Filled.AccountCircle) - } - } -} - -private enum class CourseTabs( - @StringRes val title: Int, - @DrawableRes val icon: Int -) { - MY_COURSES(R.string.my_courses, R.drawable.ic_grain), - FEATURED(R.string.featured, R.drawable.ic_featured), - SEARCH(R.string.search, R.drawable.ic_search) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt b/Owl/app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt deleted file mode 100644 index 25199acbb6..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.courses - -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.ConstraintLayout -import androidx.compose.foundation.layout.ExperimentalLayout -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.material.AmbientElevationOverlay -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.OndemandVideo -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.Course -import com.example.owl.model.courses -import com.example.owl.ui.common.OutlinedAvatar -import com.example.owl.ui.theme.BlueTheme -import com.example.owl.ui.theme.OwlTheme -import com.example.owl.ui.utils.NetworkImage -import dev.chrisbanes.accompanist.insets.statusBarsPadding -import kotlin.math.ceil - -@Composable -fun FeaturedCourses( - courses: List<Course>, - selectCourse: (Long) -> Unit, - modifier: Modifier = Modifier -) { - ScrollableColumn(modifier = modifier.statusBarsPadding()) { - CoursesAppBar() - StaggeredVerticalGrid( - maxColumnWidth = 220.dp, - modifier = Modifier.padding(4.dp) - ) { - courses.forEach { course -> - FeaturedCourse(course, selectCourse) - } - } - } -} - -@OptIn(ExperimentalLayout::class) -@Composable -fun FeaturedCourse( - course: Course, - selectCourse: (Long) -> Unit, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier.padding(4.dp), - color = MaterialTheme.colors.surface, - elevation = OwlTheme.elevations.card, - shape = MaterialTheme.shapes.medium - ) { - val featuredString = stringResource(id = R.string.featured) - ConstraintLayout( - modifier = Modifier - .clickable( - onClick = { selectCourse(course.id) } - ) - .semantics { - contentDescription = featuredString - } - ) { - val (image, avatar, subject, name, steps, icon) = createRefs() - NetworkImage( - url = course.thumbUrl, - modifier = Modifier - .aspectRatio(4f / 3f) - .constrainAs(image) { - centerHorizontallyTo(parent) - top.linkTo(parent.top) - } - ) - val outlineColor = AmbientElevationOverlay.current?.apply( - color = MaterialTheme.colors.surface, - elevation = OwlTheme.elevations.card - ) ?: MaterialTheme.colors.surface - OutlinedAvatar( - url = course.instructor, - outlineColor = outlineColor, - modifier = Modifier - .preferredSize(38.dp) - .constrainAs(avatar) { - centerHorizontallyTo(parent) - centerAround(image.bottom) - } - ) - Text( - text = course.subject.toUpperCase(), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.overline, - modifier = Modifier - .padding(16.dp) - .constrainAs(subject) { - centerHorizontallyTo(parent) - top.linkTo(avatar.bottom) - } - ) - Text( - text = course.name, - style = MaterialTheme.typography.subtitle1, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(horizontal = 16.dp) - .constrainAs(name) { - centerHorizontallyTo(parent) - top.linkTo(subject.bottom) - } - ) - val center = createGuidelineFromStart(0.5f) - Icon( - imageVector = Icons.Rounded.OndemandVideo, - tint = MaterialTheme.colors.primary, - modifier = Modifier - .preferredSize(16.dp) - .constrainAs(icon) { - end.linkTo(center) - centerVerticallyTo(steps) - } - ) - Text( - text = course.steps.toString(), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.subtitle2, - modifier = Modifier - .padding( - start = 4.dp, - top = 16.dp, - bottom = 16.dp - ) - .constrainAs(steps) { - start.linkTo(center) - top.linkTo(name.bottom) - } - ) - } - } -} - -@Composable -fun StaggeredVerticalGrid( - modifier: Modifier = Modifier, - maxColumnWidth: Dp, - content: @Composable () -> Unit -) { - Layout( - content = content, - modifier = modifier - ) { measurables, constraints -> - check(constraints.hasBoundedWidth) { - "Unbounded width not supported" - } - val columns = ceil(constraints.maxWidth / maxColumnWidth.toPx()).toInt() - val columnWidth = constraints.maxWidth / columns - val itemConstraints = constraints.copy(maxWidth = columnWidth) - val colHeights = IntArray(columns) { 0 } // track each column's height - val placeables = measurables.map { measurable -> - val column = shortestColumn(colHeights) - val placeable = measurable.measure(itemConstraints) - colHeights[column] += placeable.height - placeable - } - - val height = colHeights.maxOrNull()?.coerceIn(constraints.minHeight, constraints.maxHeight) - ?: constraints.minHeight - layout( - width = constraints.maxWidth, - height = height - ) { - val colY = IntArray(columns) { 0 } - placeables.forEach { placeable -> - val column = shortestColumn(colY) - placeable.place( - x = columnWidth * column, - y = colY[column] - ) - colY[column] += placeable.height - } - } - } -} - -private fun shortestColumn(colHeights: IntArray): Int { - var minHeight = Int.MAX_VALUE - var column = 0 - colHeights.forEachIndexed { index, height -> - if (height < minHeight) { - minHeight = height - column = index - } - } - return column -} - -@Preview(name = "Featured Course") -@Composable -private fun FeaturedCoursePreview() { - BlueTheme { - FeaturedCourse( - course = courses.first(), - selectCourse = { } - ) - } -} - -@Preview(name = "Featured Courses Portrait") -@Composable -private fun FeaturedCoursesPreview() { - BlueTheme { - FeaturedCourses( - courses = courses, - selectCourse = { } - ) - } -} - -@Preview(name = "Featured Courses Dark") -@Composable -private fun FeaturedCoursesPreviewDark() { - BlueTheme(darkTheme = true) { - FeaturedCourses( - courses = courses, - selectCourse = { } - ) - } -} - -@Preview( - name = "Featured Courses Landscape", - widthDp = 640, - heightDp = 360 -) -@Composable -private fun FeaturedCoursesPreviewLandscape() { - BlueTheme { - FeaturedCourses( - courses = courses, - selectCourse = { } - ) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/courses/MyCourses.kt b/Owl/app/src/main/java/com/example/owl/ui/courses/MyCourses.kt deleted file mode 100644 index 5a3896f37f..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/courses/MyCourses.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.courses - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.owl.model.Course -import com.example.owl.model.courses -import com.example.owl.ui.common.CourseListItem -import com.example.owl.ui.theme.BlueTheme -import dev.chrisbanes.accompanist.insets.statusBarsHeight - -@Composable -fun MyCourses( - courses: List<Course>, - selectCourse: (Long) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn(modifier) { - item { - Spacer(Modifier.statusBarsHeight()) - } - item { - CoursesAppBar() - } - itemsIndexed(courses) { index, course -> - MyCourse(course, index, selectCourse) - } - } -} - -@Composable -fun MyCourse( - course: Course, - index: Int, - selectCourse: (Long) -> Unit -) { - Row(modifier = Modifier.padding(bottom = 8.dp)) { - val stagger = if (index % 2 == 0) 72.dp else 16.dp - Spacer(modifier = Modifier.preferredWidth(stagger)) - CourseListItem( - course = course, - onClick = { selectCourse(course.id) }, - shape = RoundedCornerShape(topLeft = 24.dp), - modifier = Modifier.preferredHeight(96.dp) - ) - } -} - -@Preview(name = "My Courses") -@Composable -private fun MyCoursesPreview() { - BlueTheme { - MyCourses( - courses = courses, - selectCourse = { } - ) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/courses/SearchCourses.kt b/Owl/app/src/main/java/com/example/owl/ui/courses/SearchCourses.kt deleted file mode 100644 index 430a0153ff..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/courses/SearchCourses.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.courses - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.AmbientContentColor -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.Topic -import com.example.owl.model.topics -import com.example.owl.ui.theme.BlueTheme -import dev.chrisbanes.accompanist.insets.statusBarsPadding - -@Composable -fun SearchCourses( - topics: List<Topic>, - modifier: Modifier = Modifier -) { - val (searchTerm, updateSearchTerm) = remember { mutableStateOf(TextFieldValue("")) } - ScrollableColumn(modifier = modifier.statusBarsPadding()) { - AppBar(searchTerm, updateSearchTerm) - val filteredTopics = getTopics(searchTerm.text, topics) - filteredTopics.forEach { topic -> - Text( - text = topic.name, - style = MaterialTheme.typography.h5, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = { /* todo */ }) - .padding( - start = 16.dp, - top = 8.dp, - end = 16.dp, - bottom = 8.dp - ) - .wrapContentWidth(Alignment.Start) - ) - } - } -} - -/** - * This logic should live outside UI, but full arch omitted for simplicity in this sample. - */ -private fun getTopics( - searchTerm: String, - topics: List<Topic> -): List<Topic> { - return if (searchTerm != "") { - topics.filter { it.name.contains(searchTerm, ignoreCase = true) } - } else { - topics - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun AppBar( - searchTerm: TextFieldValue, - updateSearchTerm: (TextFieldValue) -> Unit -) { - TopAppBar(elevation = 0.dp) { - Image( - imageVector = vectorResource(id = R.drawable.ic_search), - modifier = Modifier - .padding(16.dp) - .align(Alignment.CenterVertically) - ) - // TODO hint - BasicTextField( - value = searchTerm, - onValueChange = updateSearchTerm, - textStyle = MaterialTheme.typography.subtitle1.copy( - color = AmbientContentColor.current - ), - maxLines = 1, - cursorColor = AmbientContentColor.current, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) - IconButton( - modifier = Modifier.align(Alignment.CenterVertically), - onClick = { /* todo */ } - ) { - Icon(Icons.Filled.AccountCircle) - } - } -} - -@Preview(name = "Search Courses") -@Composable -private fun FeaturedCoursesPreview() { - BlueTheme { - SearchCourses(topics, Modifier) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt b/Owl/app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt deleted file mode 100644 index ea48a9de6e..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/onboarding/Onboarding.kt +++ /dev/null @@ -1,307 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.onboarding - -import androidx.compose.animation.DpPropKey -import androidx.compose.animation.core.FloatPropKey -import androidx.compose.animation.core.transitionDefinition -import androidx.compose.animation.transition -import androidx.compose.foundation.Image -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.ContentAlpha -import androidx.compose.material.FloatingActionButton -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Done -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.rounded.Explore -import androidx.compose.material.primarySurface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.example.owl.R -import com.example.owl.model.Topic -import com.example.owl.model.topics -import com.example.owl.ui.theme.OwlTheme -import com.example.owl.ui.theme.YellowTheme -import com.example.owl.ui.theme.pink500 -import com.example.owl.ui.utils.NetworkImage -import dev.chrisbanes.accompanist.insets.navigationBarsPadding -import dev.chrisbanes.accompanist.insets.statusBarsPadding -import kotlin.math.max - -@Composable -fun Onboarding(onboardingComplete: () -> Unit) { - YellowTheme { - Scaffold( - topBar = { AppBar() }, - backgroundColor = MaterialTheme.colors.primarySurface, - floatingActionButton = { - val fabLabel = stringResource(id = R.string.continue_to_courses) - FloatingActionButton( - onClick = onboardingComplete, - modifier = Modifier - .navigationBarsPadding() - .semantics { - contentDescription = fabLabel - } - ) { - Icon(Icons.Rounded.Explore) - } - } - ) { innerPadding -> - Column( - modifier = Modifier - .statusBarsPadding() - .navigationBarsPadding() - .padding(innerPadding) - ) { - Text( - text = stringResource(R.string.choose_topics_that_interest_you), - style = MaterialTheme.typography.h4, - textAlign = TextAlign.End, - modifier = Modifier.padding( - horizontal = 16.dp, - vertical = 32.dp - ) - ) - TopicsGrid( - modifier = Modifier - .weight(1f) - .wrapContentHeight() - ) - Spacer(Modifier.preferredHeight(56.dp)) // center grid accounting for FAB - } - } - } -} - -@Composable -private fun AppBar() { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - ) { - Image( - imageVector = vectorResource(id = OwlTheme.images.lockupLogo), - modifier = Modifier.padding(16.dp) - ) - IconButton( - modifier = Modifier.padding(16.dp), - onClick = { /* todo */ } - ) { - Icon(Icons.Filled.Settings) - } - } -} - -@Composable -private fun TopicsGrid(modifier: Modifier = Modifier) { - StaggeredGrid( - modifier = modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 8.dp) - ) { - topics.forEach { topic -> - TopicChip(topic = topic) - } - } -} - -private enum class SelectionState { Unselected, Selected } - -private val CornerRadius = DpPropKey("Corner Radius") -private val SelectedAlpha = FloatPropKey("Selected Alpha") -private val CheckScale = FloatPropKey("Check Scale") - -private val TopicSelect = transitionDefinition<SelectionState> { - state(SelectionState.Selected) { - this[CornerRadius] = 28.dp - this[SelectedAlpha] = 0.8f - this[CheckScale] = 1f - } - state(SelectionState.Unselected) { - this[CornerRadius] = 0.dp - this[SelectedAlpha] = 0f - this[CheckScale] = 0.6f - } -} - -@Composable -private fun TopicChip(topic: Topic) { - val (selected, onSelected) = remember { mutableStateOf(false) } - val selectionState = transition( - definition = TopicSelect, - toState = if (selected) SelectionState.Selected else SelectionState.Unselected - ) - Surface( - modifier = Modifier.padding(4.dp), - elevation = OwlTheme.elevations.card, - shape = MaterialTheme.shapes.medium.copy(topLeft = CornerSize(selectionState[CornerRadius])) - ) { - Row(modifier = Modifier.toggleable(value = selected, onValueChange = onSelected)) { - Box { - NetworkImage( - url = topic.imageUrl, - modifier = Modifier - .preferredSize(width = 72.dp, height = 72.dp) - .aspectRatio(1f) - ) - val selectedAlpha = selectionState[SelectedAlpha] - if (selectedAlpha > 0f) { - Surface( - color = pink500.copy(alpha = selectedAlpha), - modifier = Modifier.matchParentSize() - ) { - Icon( - imageVector = Icons.Filled.Done, - tint = MaterialTheme.colors.onPrimary.copy(alpha = selectedAlpha), - modifier = Modifier.scale(selectionState[CheckScale]) - ) - } - } - } - Column { - Text( - text = topic.name, - style = MaterialTheme.typography.body1, - modifier = Modifier.padding( - start = 16.dp, - top = 16.dp, - end = 16.dp, - bottom = 8.dp - ) - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Icon( - imageVector = vectorResource(R.drawable.ic_grain), - modifier = Modifier - .padding(start = 16.dp) - .preferredSize(12.dp) - ) - Text( - text = topic.courses.toString(), - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(start = 8.dp) - ) - } - } - } - } - } -} - -@Composable -private fun StaggeredGrid( - modifier: Modifier = Modifier, - rows: Int = 3, - content: @Composable () -> Unit -) { - Layout( - content = content, - modifier = modifier - ) { measurables, constraints -> - val rowWidths = IntArray(rows) { 0 } // Keep track of the width of each row - val rowHeights = IntArray(rows) { 0 } // Keep track of the height of each row - - // Don't constrain child views further, measure them with given constraints - val placeables = measurables.mapIndexed { index, measurable -> - val placeable = measurable.measure(constraints) - - // Track the width and max height of each row - val row = index % rows - rowWidths[row] += placeable.width - rowHeights[row] = max(rowHeights[row], placeable.height) - - placeable - } - - // Grid's width is the widest row - val width = rowWidths.maxOrNull()?.coerceIn(constraints.minWidth, constraints.maxWidth) - ?: constraints.minWidth - // Grid's height is the sum of each row - val height = rowHeights.sum().coerceIn(constraints.minHeight, constraints.maxHeight) - - // y co-ord of each row - val rowY = IntArray(rows) { 0 } - for (i in 1 until rows) { - rowY[i] = rowY[i - 1] + rowHeights[i - 1] - } - layout(width, height) { - // x co-ord we have placed up to, per row - val rowX = IntArray(rows) { 0 } - placeables.forEachIndexed { index, placeable -> - val row = index % rows - placeable.place( - x = rowX[row], - y = rowY[row] - ) - rowX[row] += placeable.width - } - } - } -} - -@Preview(name = "Onboarding") -@Composable -private fun OnboardingPreview() { - Onboarding(onboardingComplete = { }) -} - -@Preview("Topic Chip") -@Composable -private fun TopicChipPreview() { - YellowTheme { - TopicChip(topics.first()) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Color.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Color.kt deleted file mode 100644 index 1d38c957b4..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Color.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.material.Colors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver - -val yellow200 = Color(0xffffeb46) -val yellow400 = Color(0xffffc000) -val yellow500 = Color(0xffffde03) -val yellowDarkPrimary = Color(0xff242316) - -val blue200 = Color(0xff91a4fc) -val blue700 = Color(0xff0336ff) -val blue800 = Color(0xff0035c9) -val blueDarkPrimary = Color(0xff1c1d24) - -val pink200 = Color(0xffff7597) -val pink500 = Color(0xffff0266) -val pink600 = Color(0xffd8004d) -val pinkDarkPrimary = Color(0xff24191c) - -/** - * Return the fully opaque color that results from compositing [onSurface] atop [surface] with the - * given [alpha]. Useful for situations where semi-transparent colors are undesirable. - */ -@Composable -fun Colors.compositedOnSurface(alpha: Float): Color { - return onSurface.copy(alpha = alpha).compositeOver(surface) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Elevations.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Elevations.kt deleted file mode 100644 index c82d8399b2..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Elevations.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.staticAmbientOf -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -/** - * Elevation values that can be themed. - */ -@Immutable -data class Elevations(val card: Dp = 0.dp) - -internal val AmbientElevations = staticAmbientOf { Elevations() } diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Images.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Images.kt deleted file mode 100644 index 2d7596f9a2..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Images.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.annotation.DrawableRes -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.staticAmbientOf - -/** - * Images that can vary by theme. - */ -@Immutable -data class Images(@DrawableRes val lockupLogo: Int) - -internal val AmbientImages = staticAmbientOf<Images>() diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Shape.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Shape.kt deleted file mode 100644 index 348d4665ea..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Shape.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -val shapes = Shapes( - small = RoundedCornerShape(percent = 50), - medium = RoundedCornerShape(size = 0f), - large = RoundedCornerShape( - topLeft = 16.dp, - topRight = 0.dp, - bottomRight = 0.dp, - bottomLeft = 16.dp - ) -) diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Theme.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Theme.kt deleted file mode 100644 index 6f8aabe94f..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Theme.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Shapes -import androidx.compose.material.Typography -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.example.owl.R - -private val YellowThemeLight = lightColors( - primary = yellow500, - primaryVariant = yellow400, - onPrimary = Color.Black, - secondary = blue700, - secondaryVariant = blue800, - onSecondary = Color.White -) - -private val YellowThemeDark = darkColors( - primary = yellow200, - secondary = blue200, - onSecondary = Color.Black, - surface = yellowDarkPrimary -) - -@Composable -fun YellowTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colors = if (darkTheme) { - YellowThemeDark - } else { - YellowThemeLight - } - OwlTheme(darkTheme, colors, content) -} - -private val BlueThemeLight = lightColors( - primary = blue700, - onPrimary = Color.White, - primaryVariant = blue800, - secondary = yellow500 -) - -private val BlueThemeDark = darkColors( - primary = blue200, - secondary = yellow200, - surface = blueDarkPrimary -) - -@Composable -fun BlueTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colors = if (darkTheme) { - BlueThemeDark - } else { - BlueThemeLight - } - OwlTheme(darkTheme, colors, content) -} - -private val PinkThemeLight = lightColors( - primary = pink500, - secondary = pink500, - primaryVariant = pink600, - onPrimary = Color.Black -) - -private val PinkThemeDark = darkColors( - primary = pink200, - secondary = pink200, - surface = pinkDarkPrimary -) - -@Composable -fun PinkTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colors = if (darkTheme) { - PinkThemeDark - } else { - PinkThemeLight - } - OwlTheme(darkTheme, colors, content) -} - -private val LightElevation = Elevations() - -private val DarkElevation = Elevations(card = 1.dp) - -private val LightImages = Images(lockupLogo = R.drawable.ic_lockup_blue) - -private val DarkImages = Images(lockupLogo = R.drawable.ic_lockup_white) - -@Composable -private fun OwlTheme( - darkTheme: Boolean, - colors: Colors, - content: @Composable () -> Unit -) { - val elevation = if (darkTheme) DarkElevation else LightElevation - val images = if (darkTheme) DarkImages else LightImages - Providers( - AmbientElevations provides elevation, - AmbientImages provides images - ) { - MaterialTheme( - colors = colors, - typography = typography, - shapes = shapes, - content = content - ) - } -} - -/** - * Alternate to [MaterialTheme] allowing us to add our own theme systems (e.g. [Elevations]) or to - * extend [MaterialTheme]'s types e.g. return our own [Colors] extension - */ -object OwlTheme { - - /** - * Proxy to [MaterialTheme] - */ - val colors: Colors - @Composable - get() = MaterialTheme.colors - - /** - * Proxy to [MaterialTheme] - */ - val typography: Typography - @Composable - get() = MaterialTheme.typography - - /** - * Proxy to [MaterialTheme] - */ - val shapes: Shapes - @Composable - get() = MaterialTheme.shapes - - /** - * Retrieves the current [Elevations] at the call site's position in the hierarchy. - */ - val elevations: Elevations - @Composable - get() = AmbientElevations.current - - /** - * Retrieves the current [Images] at the call site's position in the hierarchy. - */ - val images: Images - @Composable - get() = AmbientImages.current -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/theme/Type.kt b/Owl/app/src/main/java/com/example/owl/ui/theme/Type.kt deleted file mode 100644 index 06d145e1db..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/theme/Type.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.theme - -import androidx.compose.material.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.font.font -import androidx.compose.ui.text.font.fontFamily -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp -import com.example.owl.R - -private val fonts = fontFamily( - font(R.font.rubik_regular), - font(R.font.rubik_medium, FontWeight.W500), - font(R.font.rubik_bold, FontWeight.Bold) -) - -val typography = typographyFromDefaults( - h1 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - h2 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - h3 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - h4 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold, - lineHeight = 40.sp - ), - h5 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - h6 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.W500, - lineHeight = 28.sp - ), - subtitle1 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.W500, - lineHeight = 22.sp - ), - subtitle2 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.W500 - ), - body1 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Normal, - lineHeight = 28.sp - ), - body2 = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Normal, - lineHeight = 16.sp - ), - button = TextStyle( - fontFamily = fonts, - fontWeight = FontWeight.Bold - ), - caption = TextStyle( - fontFamily = fonts - ), - overline = TextStyle( - letterSpacing = 0.08.em - ) -) - -fun typographyFromDefaults( - h1: TextStyle?, - h2: TextStyle?, - h3: TextStyle?, - h4: TextStyle?, - h5: TextStyle?, - h6: TextStyle?, - subtitle1: TextStyle?, - subtitle2: TextStyle?, - body1: TextStyle?, - body2: TextStyle?, - button: TextStyle?, - caption: TextStyle?, - overline: TextStyle? -): Typography { - val defaults = Typography() - return Typography( - h1 = defaults.h1.merge(h1), - h2 = defaults.h2.merge(h2), - h3 = defaults.h3.merge(h3), - h4 = defaults.h4.merge(h4), - h5 = defaults.h5.merge(h5), - h6 = defaults.h6.merge(h6), - subtitle1 = defaults.subtitle1.merge(subtitle1), - subtitle2 = defaults.subtitle2.merge(subtitle2), - body1 = defaults.body1.merge(body1), - body2 = defaults.body2.merge(body2), - button = defaults.button.merge(button), - caption = defaults.caption.merge(caption), - overline = defaults.overline.merge(overline) - ) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/utils/Lerp.kt b/Owl/app/src/main/java/com/example/owl/ui/utils/Lerp.kt deleted file mode 100644 index 0710e69b74..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/utils/Lerp.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.utils - -import androidx.annotation.FloatRange -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp as lerpColor - -/** - * Linearly interpolate between two values - */ -fun lerp( - startValue: Float, - endValue: Float, - @FloatRange(from = 0.0, to = 1.0) fraction: Float -): Float { - return startValue + fraction * (endValue - startValue) -} - -/** - * Linearly interpolate between two [Float]s when the [fraction] is in a given range. - */ -fun lerp( - startValue: Float, - endValue: Float, - @FloatRange(from = 0.0, to = 1.0) startFraction: Float, - @FloatRange(from = 0.0, to = 1.0) endFraction: Float, - @FloatRange(from = 0.0, to = 1.0) fraction: Float -): Float { - if (fraction < startFraction) return startValue - if (fraction > endFraction) return endValue - - return lerp(startValue, endValue, (fraction - startFraction) / (endFraction - startFraction)) -} - -/** - * Linearly interpolate between two [Color]s when the [fraction] is in a given range. - */ -fun lerp( - startColor: Color, - endColor: Color, - @FloatRange(from = 0.0, to = 1.0) startFraction: Float, - @FloatRange(from = 0.0, to = 1.0) endFraction: Float, - @FloatRange(from = 0.0, to = 1.0) fraction: Float -): Color { - if (fraction < startFraction) return startColor - if (fraction > endFraction) return endColor - - return lerpColor( - startColor, - endColor, - (fraction - startFraction) / (endFraction - startFraction) - ) -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/utils/Navigation.kt b/Owl/app/src/main/java/com/example/owl/ui/utils/Navigation.kt deleted file mode 100644 index c67f609221..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/utils/Navigation.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.utils - -import androidx.activity.OnBackPressedCallback -import androidx.activity.OnBackPressedDispatcher -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.staticAmbientOf - -/** - * An effect for handling presses of the device back button. - */ -@Composable -fun backHandler( - enabled: Boolean = true, - onBack: () -> Unit -) { - // Safely update the current `onBack` lambda when a new one is provided - val currentOnBack by rememberUpdatedState(onBack) - // Remember in Composition a back callback that calls the `onBack` lambda - val backCallback = remember { - object : OnBackPressedCallback(enabled) { - override fun handleOnBackPressed() { - currentOnBack() - } - } - } - // On every successful composition, update the callback with the `enabled` value - SideEffect { - backCallback.isEnabled = enabled - } - val backDispatcher = AmbientBackDispatcher.current - // If `backDispatcher` changes, dispose and reset the effect - DisposableEffect(backDispatcher) { - // Add callback to the backDispatcher - backDispatcher.addCallback(backCallback) - // When the effect leaves the Composition, remove the callback - onDispose { - backCallback.remove() - } - } -} - -/** - * An [androidx.compose.runtime.Ambient] providing the current [OnBackPressedDispatcher]. You must - * [provide][androidx.compose.runtime.Providers] a value before use. - */ -internal val AmbientBackDispatcher = staticAmbientOf<OnBackPressedDispatcher> { - error("No Back Dispatcher provided") -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/utils/NetworkImage.kt b/Owl/app/src/main/java/com/example/owl/ui/utils/NetworkImage.kt deleted file mode 100644 index 51cd087248..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/utils/NetworkImage.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.utils - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.AmbientContext -import coil.ImageLoader -import coil.annotation.ExperimentalCoilApi -import coil.intercept.Interceptor -import coil.request.ImageResult -import coil.size.PixelSize -import com.example.owl.ui.theme.compositedOnSurface -import dev.chrisbanes.accompanist.coil.AmbientImageLoader -import dev.chrisbanes.accompanist.coil.CoilImage -import okhttp3.HttpUrl - -/** - * A wrapper around [CoilImage] setting a default [contentScale] and loading placeholder. - */ -@Composable -fun NetworkImage( - url: String, - modifier: Modifier = Modifier, - contentScale: ContentScale = ContentScale.Crop, - placeholderColor: Color? = MaterialTheme.colors.compositedOnSurface(0.2f) -) { - CoilImage( - data = url, - modifier = modifier, - contentScale = contentScale, - loading = { - if (placeholderColor != null) { - Spacer( - modifier = Modifier - .fillMaxSize() - .background(placeholderColor) - ) - } - } - ) -} - -@Composable -fun ProvideImageLoader(content: @Composable () -> Unit) { - val context = AmbientContext.current - val loader = remember(context) { - ImageLoader.Builder(context) - .componentRegistry { - add(UnsplashSizingInterceptor) - }.build() - } - Providers(AmbientImageLoader provides loader, content = content) -} - -/** - * A Coil [Interceptor] which appends query params to Unsplash urls to request sized images. - */ -@OptIn(ExperimentalCoilApi::class) -object UnsplashSizingInterceptor : Interceptor { - override suspend fun intercept(chain: Interceptor.Chain): ImageResult { - val data = chain.request.data - val size = chain.size - if (data is String && - data.startsWith("https://images.unsplash.com/photo-") && - size is PixelSize && - size.width > 0 && - size.height > 0 - ) { - val url = HttpUrl.parse(data)!! - .newBuilder() - .addQueryParameter("w", size.width.toString()) - .addQueryParameter("h", size.height.toString()) - .build() - val request = chain.request.newBuilder().data(url).build() - return chain.proceed(request) - } - return chain.proceed(chain.request) - } -} diff --git a/Owl/app/src/main/java/com/example/owl/ui/utils/Scrim.kt b/Owl/app/src/main/java/com/example/owl/ui/utils/Scrim.kt deleted file mode 100644 index 77bff3e14d..0000000000 --- a/Owl/app/src/main/java/com/example/owl/ui/utils/Scrim.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.ui.utils - -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color - -/** - * A [Modifier] which draws a vertical gradient - */ -fun Modifier.scrim(colors: List<Color>): Modifier = drawWithContent { - drawContent() - drawRect(Brush.verticalGradient(colors)) -} diff --git a/Owl/app/src/main/res/drawable-v26/ic_launcher_background.xml b/Owl/app/src/main/res/drawable-v26/ic_launcher_background.xml deleted file mode 100644 index eb01b4fe9f..0000000000 --- a/Owl/app/src/main/res/drawable-v26/ic_launcher_background.xml +++ /dev/null @@ -1,29 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector - xmlns:android="http://schemas.android.com/apk/res/android" - android:width="108dp" - android:height="108dp" - android:viewportWidth="192" - android:viewportHeight="192"> - <path - android:fillColor="#1041fb" - android:fillType="evenOdd" - android:pathData="M0,0h192v192h-192z"/> - <path - android:fillColor="#ffde03" - android:fillType="evenOdd" - android:pathData="M112.942,67.116C107.382,63.952 100.899,63.166 94.688,64.905C88.493,66.638 83.337,70.662 80.165,76.233C80.153,76.259 80.103,76.354 80.091,76.379C80.061,76.439 79.97,76.609 79.591,77.276L57.193,116.608C56.999,116.945 56.95,117.345 57.052,117.72C57.154,118.092 57.402,118.411 57.74,118.603C65.984,123.295 74.465,125.561 82.58,125.561C97.876,125.561 111.858,117.5 120.433,102.43C120.469,102.368 120.498,102.302 120.524,102.237C120.857,101.655 121.047,101.323 121.153,101.141C121.189,101.091 121.224,101.039 121.255,100.984C128.044,89.053 124.238,73.543 112.942,67.116"/> -</vector> diff --git a/Owl/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Owl/app/src/main/res/drawable-v26/ic_launcher_foreground.xml deleted file mode 100644 index a61c0661ef..0000000000 --- a/Owl/app/src/main/res/drawable-v26/ic_launcher_foreground.xml +++ /dev/null @@ -1,29 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector - xmlns:android="http://schemas.android.com/apk/res/android" - android:width="108dp" - android:height="108dp" - android:viewportWidth="192" - android:viewportHeight="192"> - <path - android:fillColor="#ffde03" - android:fillType="evenOdd" - android:pathData="M123.354,75.519L134.834,86.999C135.225,87.389 135.225,88.022 134.834,88.413L123.354,99.893C122.964,100.284 122.33,100.284 121.94,99.893L110.46,88.413C110.069,88.022 110.069,87.389 110.46,86.999L121.94,75.519C122.33,75.128 122.964,75.128 123.354,75.519Z"/> - <path - android:fillColor="#ff0366" - android:fillType="evenOdd" - android:pathData="M112.942,67.116C107.382,63.952 100.899,63.166 94.688,64.905C88.493,66.638 83.337,70.662 80.165,76.233C80.153,76.259 80.103,76.354 80.091,76.379C80.061,76.439 79.97,76.609 79.591,77.276L57.193,116.608C56.999,116.945 56.95,117.345 57.052,117.72C57.154,118.092 57.402,118.411 57.74,118.603C65.984,123.295 74.465,125.561 82.58,125.561C97.876,125.561 111.858,117.5 120.433,102.43C120.469,102.368 120.498,102.302 120.524,102.237C120.857,101.655 121.047,101.323 121.153,101.141C121.189,101.091 121.224,101.039 121.255,100.984C128.044,89.053 124.238,73.543 112.942,67.116"/> -</vector> diff --git a/Owl/app/src/main/res/drawable/ic_featured.xml b/Owl/app/src/main/res/drawable/ic_featured.xml deleted file mode 100644 index 5026e385eb..0000000000 --- a/Owl/app/src/main/res/drawable/ic_featured.xml +++ /dev/null @@ -1,27 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector - xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:pathData="M12.3,20.34A8.12,8.12 0,0 1,4 12.17,8.12 8.12,0 0,1 12.3,4a8.13,8.13 0,0 1,8.3 8.17,8.13 8.13,0 0,1 -8.3,8.17zM16.05,8.14a4.84,4.84 0,0 0,-3.73 -1.67c-1.48,0 -2.72,0.56 -3.74,1.67a5.8,5.8 0,0 0,-1.52 4.04c0,1.58 0.51,2.92 1.52,4.03a4.86,4.86 0,0 0,3.74 1.66c1.47,0 2.71,-0.56 3.73,-1.66a5.75,5.75 0,0 0,1.51 -4.03,5.8 5.8,0 0,0 -1.51,-4.04z" - android:fillColor="#fff"/> - <path - android:pathData="M12.3,9.9a2.26,2.26 0,1 0,0 4.53,2.26 2.26,0 0,0 0,-4.52z" - android:fillColor="#fff"/> -</vector> diff --git a/Owl/app/src/main/res/drawable/ic_grain.xml b/Owl/app/src/main/res/drawable/ic_grain.xml deleted file mode 100644 index 03bbe544fa..0000000000 --- a/Owl/app/src/main/res/drawable/ic_grain.xml +++ /dev/null @@ -1,24 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector - xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:pathData="M9.98,3.98C11.06,3.98 12,4.92 12,6s-0.94,2.02 -2.02,2.02A2.02,2.02 0,0 1,8.02 6c0,-1.08 0.89,-2.02 1.96,-2.02zM14.02,8.02c1.07,0 1.96,0.89 1.96,1.96 0,1.08 -0.89,2.02 -1.96,2.02A2.06,2.06 0,0 1,12 9.98c0,-1.07 0.94,-1.96 2.02,-1.96zM18,12c1.08,0 2.02,0.94 2.02,2.02a2.02,2.02 0,0 1,-4.03 0c0,-1.08 0.93,-2.02 2.01,-2.02zM14.02,15.98a2.02,2.02 0,0 1,0 4.03A2.06,2.06 0,0 1,12 18c0,-1.08 0.94,-2.02 2.02,-2.02zM18,8.02A2.06,2.06 0,0 1,15.98 6c0,-1.08 0.94,-2.02 2.02,-2.02s2.02,0.94 2.02,2.02 -0.94,2.02 -2.02,2.02zM6,15.98c1.08,0 2.02,0.94 2.02,2.02S7.08,20.02 6,20.02A2.06,2.06 0,0 1,3.98 18c0,-1.08 0.94,-2.02 2.02,-2.02zM6,8.02c1.08,0 2.02,0.89 2.02,1.96C8.02,11.06 7.08,12 6,12a2.06,2.06 0,0 1,-2.02 -2.02c0,-1.07 0.94,-1.96 2.02,-1.96zM9.98,12c1.08,0 2.02,0.94 2.02,2.02 0,1.07 -0.94,1.96 -2.02,1.96 -1.07,0 -1.96,-0.89 -1.96,-1.96 0,-1.08 0.89,-2.02 1.96,-2.02z" - android:fillColor="#fff"/> -</vector> diff --git a/Owl/app/src/main/res/drawable/ic_lockup_blue.xml b/Owl/app/src/main/res/drawable/ic_lockup_blue.xml deleted file mode 100644 index f18db0a807..0000000000 --- a/Owl/app/src/main/res/drawable/ic_lockup_blue.xml +++ /dev/null @@ -1,37 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector - xmlns:android="http://schemas.android.com/apk/res/android" - android:width="104dp" - android:height="40dp" - android:viewportWidth="104" - android:viewportHeight="40"> - <path - android:pathData="M37.47,20.33l5.4,-5.4a1.17,1.17 0,0 0,0 -1.66l-5.4,-5.4a1.17,1.17 0,0 0,-1.66 0l-5.4,5.4c-0.45,0.46 6.6,7.51 7.06,7.06" - android:fillColor="#fff" - android:fillType="evenOdd"/> - <path - android:pathData="M30.68,1.7A13,13 0,0 0,20.67 0.5a13.2,13.2 0,0 0,-7.97 6.2l-0.04,0.09 -0.27,0.49L0.1,28.85a0.8,0.8 0,0 0,0.3 1.1,27.55 27.55,0 0,0 13.62,3.81c8.39,0 16.05,-4.42 20.76,-12.69a0.73,0.73 0,0 0,0.05 -0.1l0.34,-0.6a0.85,0.85 0,0 0,0.06 -0.09c3.72,-6.54 1.63,-15.05 -4.56,-18.57" - android:fillColor="#ff0366" - android:fillType="evenOdd"/> - <path - android:pathData="M42.57,36.3a12.81,12.81 0,0 1,-9.3 3.7,12.8 12.8,0 0,1 -9.32,-3.7 12.32,12.32 0,0 1,-3.78 -9.18c0,-3.66 1.26,-6.72 3.78,-9.19a12.8,12.8 0,0 1,9.31 -3.69c3.69,0 6.8,1.23 9.31,3.7a12.32,12.32 0,0 1,3.78 9.18c0,3.66 -1.26,6.73 -3.78,9.19m-1.89,-9.17c0,-2.23 -0.71,-4.12 -2.14,-5.7a6.84,6.84 0,0 0,-5.26 -2.35c-2.08,0 -3.84,0.79 -5.26,2.36a8.16,8.16 0,0 0,-2.14 5.69,8.1 8.1,0 0,0 2.14,5.67 6.86,6.86 0,0 0,5.26 2.34c2.08,0 3.83,-0.78 5.26,-2.34a8.1,8.1 0,0 0,2.14 -5.67M78.04,14.81l-4.96,14.34 -4.4,-14.34h-5.8l-4.43,14.34 -4.92,-14.34H47.5l8.7,24.94h4.14l5.42,-17.34 5.46,17.34h4.14l8.7,-24.94zM87.03,14.81v24.94h16.2v-4.96H92.58V14.81z" - android:fillColor="#0235ff" - android:fillType="evenOdd"/> - <path - android:pathData="M36.78,26.93a3.57,3.57 0,1 0,-7.14 0,3.57 3.57,0 0,0 7.14,0" - android:fillColor="#0235ff" - android:fillType="evenOdd"/> -</vector> diff --git a/Owl/app/src/main/res/drawable/ic_lockup_white.xml b/Owl/app/src/main/res/drawable/ic_lockup_white.xml deleted file mode 100644 index b467d362f5..0000000000 --- a/Owl/app/src/main/res/drawable/ic_lockup_white.xml +++ /dev/null @@ -1,40 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector - xmlns:android="http://schemas.android.com/apk/res/android" - android:width="104dp" - android:height="40dp" - android:viewportWidth="104" - android:viewportHeight="40"> - <path - android:pathData="M37.47,20.33l5.4,-5.4a1.17,1.17 0,0 0,0 -1.66l-5.4,-5.4a1.17,1.17 0,0 0,-1.66 0l-5.4,5.4c-0.45,0.46 6.6,7.51 7.06,7.06" - android:fillColor="#ffde03" - android:fillType="evenOdd"/> - <path - android:pathData="M30.68,1.7A13,13 0,0 0,20.67 0.5a13.2,13.2 0,0 0,-7.97 6.2l-0.04,0.09 -0.27,0.49L0.1,28.85a0.8,0.8 0,0 0,0.3 1.1,27.55 27.55,0 0,0 13.62,3.81c8.39,0 16.05,-4.42 20.76,-12.69a0.73,0.73 0,0 0,0.05 -0.1l0.34,-0.6a0.85,0.85 0,0 0,0.06 -0.09c3.72,-6.54 1.63,-15.05 -4.56,-18.57" - android:fillColor="#ff0366" - android:fillType="evenOdd"/> - <group> - <clip-path android:pathData="M46.35,40L20.17,40L20.17,14.24h26.18L46.35,40z M 0,0"/> - <path - android:pathData="M42.57,36.3a12.81,12.81 0,0 1,-9.3 3.7,12.8 12.8,0 0,1 -9.32,-3.7 12.32,12.32 0,0 1,-3.78 -9.18c0,-3.66 1.26,-6.72 3.78,-9.19A12.8,12.8 0,0 1,33.26 14.24c3.69,0 6.8,1.23 9.31,3.7a12.32,12.32 0,0 1,3.78 9.18c0,3.66 -1.26,6.73 -3.78,9.19m-1.89,-9.17c0,-2.23 -0.71,-4.12 -2.14,-5.7a6.84,6.84 0,0 0,-5.26 -2.35c-2.08,0 -3.84,0.79 -5.26,2.36a8.16,8.16 0,0 0,-2.14 5.69,8.1 8.1,0 0,0 2.14,5.67 6.86,6.86 0,0 0,5.26 2.34c2.08,0 3.83,-0.78 5.26,-2.34a8.1,8.1 0,0 0,2.14 -5.67" - android:fillColor="#fff" - android:fillType="evenOdd"/> - </group> - <path - android:pathData="M78.04,14.81l-4.96,14.34 -4.4,-14.34h-5.8l-4.43,14.34 -4.92,-14.34H47.5l8.7,24.94h4.14l5.42,-17.34 5.46,17.34h4.14l8.7,-24.94zM87.03,14.81v24.94h16.2v-4.96H92.58V14.81zM36.78,26.93a3.57,3.57 0,1 0,-7.14 0,3.57 3.57,0 0,0 7.14,0" - android:fillColor="#fff" - android:fillType="evenOdd"/> -</vector> diff --git a/Owl/app/src/main/res/drawable/ic_logo.xml b/Owl/app/src/main/res/drawable/ic_logo.xml deleted file mode 100644 index 62d9a45664..0000000000 --- a/Owl/app/src/main/res/drawable/ic_logo.xml +++ /dev/null @@ -1,33 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector - xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="32" - android:viewportHeight="32"> - <path - android:pathData="M23.96,13.43L18.7,8.17a1.15,1.15 0,0 0,-1.62 0l-5.26,5.26a1.14,1.14 0,0 0,0 1.63l5.26,5.26a1.15,1.15 0,0 0,1.62 0l5.27,-5.26c0.45,-0.45 0.45,-1.18 0,-1.63" - android:fillColor="#ffde03" - android:fillType="evenOdd"/> - <path - android:pathData="M17.88,26.39A12.76,12.76 0,0 1,5.14 13.64C5.14,6.61 10.86,0.9 17.88,0.9c7.03,0 12.75,5.71 12.75,12.74S24.9,26.4 17.88,26.4m0,-20.71a7.97,7.97 0,0 0,0 15.92,7.97 7.97,0 0,0 0,-15.92" - android:fillColor="#fff" - android:fillType="evenOdd"/> - <path - android:pathData="M21.8,18.36a7.13,7.13 0,0 0,-3.38 -4.68,7.44 7.44,0 0,0 -10.13,2.81l-0.03,0.06 -0.14,0.25 -6.65,11.7a0.71,0.71 0,0 0,0.26 0.97,15.28 15.28,0 0,0 7.53,2.1c1.1,0 2.18,-0.14 3.23,-0.42a13.55,13.55 0,0 0,8.5 -7.02,8.09 8.09,0 0,0 0.81,-5.77" - android:fillColor="#ff0366" - android:fillType="evenOdd"/> -</vector> diff --git a/Owl/app/src/main/res/drawable/ic_search.xml b/Owl/app/src/main/res/drawable/ic_search.xml deleted file mode 100644 index 926df864f6..0000000000 --- a/Owl/app/src/main/res/drawable/ic_search.xml +++ /dev/null @@ -1,24 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<vector - xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:pathData="M19.8,18.8l-3,-3c1.2,-1.3 1.8,-3.1 1.8,-5a7.81,7.81 0,1 0,-7.8 7.8c1.4,0 2.7,-0.4 3.9,-1l3.2,3.2c0.1,0.1 0.3,0.2 0.5,0.2s0.4,-0.1 0.5,-0.2l0.8,-0.8c0.4,-0.4 0.4,-0.9 0.1,-1.2zM15.9,10.8c0,2.8 -2.2,5 -5,5 -2.7,0 -5,-2.2 -5,-5s2.2,-5 5,-5c2.8,0.1 5,2.2 5,5z" - android:fillColor="#fff"/> -</vector> diff --git a/Owl/app/src/main/res/font/rubik_bold.ttf b/Owl/app/src/main/res/font/rubik_bold.ttf deleted file mode 100755 index 4e77930f42..0000000000 Binary files a/Owl/app/src/main/res/font/rubik_bold.ttf and /dev/null differ diff --git a/Owl/app/src/main/res/font/rubik_medium.ttf b/Owl/app/src/main/res/font/rubik_medium.ttf deleted file mode 100755 index 9e358b2f40..0000000000 Binary files a/Owl/app/src/main/res/font/rubik_medium.ttf and /dev/null differ diff --git a/Owl/app/src/main/res/font/rubik_regular.ttf b/Owl/app/src/main/res/font/rubik_regular.ttf deleted file mode 100755 index 52b59ca4fd..0000000000 Binary files a/Owl/app/src/main/res/font/rubik_regular.ttf and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Owl/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 1a1449ec8a..0000000000 --- a/Owl/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,18 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@drawable/ic_launcher_background" /> - <foreground android:drawable="@drawable/ic_launcher_foreground" /> -</adaptive-icon> diff --git a/Owl/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100755 index 8abe367a25..0000000000 Binary files a/Owl/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100755 index 1f30502cb3..0000000000 Binary files a/Owl/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100755 index df8e3904a3..0000000000 Binary files a/Owl/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100755 index 13a8dd1bdd..0000000000 Binary files a/Owl/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Owl/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100755 index 9434fca446..0000000000 Binary files a/Owl/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Owl/app/src/main/res/values-night/themes.xml b/Owl/app/src/main/res/values-night/themes.xml deleted file mode 100644 index d444fdea51..0000000000 --- a/Owl/app/src/main/res/values-night/themes.xml +++ /dev/null @@ -1,19 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<resources> - - <style name="Theme.Material.DayNight.NoActionBar" parent="@android:style/Theme.Material.NoActionBar" /> - -</resources> diff --git a/Owl/app/src/main/res/values-v29/color.xml b/Owl/app/src/main/res/values-v29/color.xml deleted file mode 100644 index 223b20c43d..0000000000 --- a/Owl/app/src/main/res/values-v29/color.xml +++ /dev/null @@ -1,19 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<resources> - - <color name="nav_bar">@android:color/transparent</color> - -</resources> diff --git a/Owl/app/src/main/res/values/color.xml b/Owl/app/src/main/res/values/color.xml deleted file mode 100644 index 57a447fe9e..0000000000 --- a/Owl/app/src/main/res/values/color.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<resources> - - <color name="immersive_sys_ui">#33000000</color> - <color name="nav_bar">@color/immersive_sys_ui</color> - -</resources> diff --git a/Owl/app/src/main/res/values/strings.xml b/Owl/app/src/main/res/values/strings.xml deleted file mode 100644 index 7d50fece2f..0000000000 --- a/Owl/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,37 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<resources> - <string name="app_name">Owl</string> - - <!-- Onboarding --> - <string name="choose_topics_that_interest_you">Choose topics that interest you</string> - <string name="continue_to_courses">Continue to courses</string> - - <!-- Courses --> - <string name="my_courses">My Courses</string> - <string name="featured">Featured</string> - <string name="search">Search</string> - <string name="course_step_steps">%1$d / %2$d</string> - - <!--sample data--> - <string name="course_desc">This video course introduces the photography of structures, including urban and rural buildings, monuments, and less traditional structures. Instruction includes the handling of equipment and methods used to capture building interiors and exteriors. The discussion will be about the handling of distortion, varied light sources, and perspective.</string> - <string name="what_you_ll_need">What You\'ll Need:</string> - <string name="you_ll_also_like">You\'ll Also Like</string> - <string name="needs">• DSLR or manual camera\n• 24mm wide angle lens\n• Tripod</string> - <string name="step_0">Identify the right aperture for your scene\'s depth of field to achieve the best clarity. Use a narrower aperture so that the entire scene remains in focus.</string> - <string name="step_1">Frame the subject of your landscape by making use of the lines and angles present in your scene. Look out for curves, textures, triangles, thirds, and other shapes that improve your composition.</string> - <string name="step_2">Find the time of the day with the best light conditions for your scene. Balance the light sources in your composition so that both light and shadows are present, adding depth, texture, and scale to your landscape.</string> - -</resources> diff --git a/Owl/app/src/main/res/values/themes.xml b/Owl/app/src/main/res/values/themes.xml deleted file mode 100644 index 33ea323956..0000000000 --- a/Owl/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - 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. - --> -<resources> - - <style name="Theme.Owl" parent="@style/Theme.Material.DayNight.NoActionBar"> - <item name="android:colorPrimary">#ff00ff</item> - <item name="android:colorAccent">#ff00ff</item> - <item name="android:statusBarColor">@color/immersive_sys_ui</item> - <item name="android:navigationBarColor">@color/nav_bar</item> - </style> - - <style name="Theme.Material.DayNight.NoActionBar" parent="@android:style/Theme.Material.Light.NoActionBar" /> - -</resources> diff --git a/Owl/build.gradle b/Owl/build.gradle deleted file mode 100644 index fd710aa5c4..0000000000 --- a/Owl/build.gradle +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.owl.buildsrc.Libs -import com.example.owl.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.8.2' -} - -subprojects { - repositories { - google() - jcenter() - - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { - url "https://androidx.dev/snapshots/builds/${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" - } - } - - maven { url 'https://oss.sonatype.org/content/repositories/snapshots' } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - jvmTarget = '1.8' - allWarningsAsErrors = true - // Opt-in to experimental compose APIs - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - freeCompilerArgs += '-Xallow-jvm-ir-dependencies' - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - ktlint(Versions.ktlint).userData([android: "true"]) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } -} diff --git a/Owl/buildSrc/build.gradle.kts b/Owl/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Owl/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Owl/buildSrc/src/main/java/com/example/owl/buildsrc/Dependencies.kt b/Owl/buildSrc/src/main/java/com/example/owl/buildsrc/Dependencies.kt deleted file mode 100644 index c9279f2f10..0000000000 --- a/Owl/buildSrc/src/main/java/com/example/owl/buildsrc/Dependencies.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.owl.buildsrc - -object Versions { - const val ktlint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha04" - const val junit = "junit:junit:4.13" - - object Accompanist { - private const val version = "0.4.2" - const val coil = "dev.chrisbanes.accompanist:accompanist-coil:$version" - const val insets = "dev.chrisbanes.accompanist:accompanist-insets:$version" - } - - object Kotlin { - private const val version = "1.4.21" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.1" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object AndroidX { - const val coreKtx = "androidx.core:core-ktx:1.5.0-beta01" - const val navigation = "androidx.navigation:navigation-compose:1.0.0-alpha05" - - object Compose { - const val snapshot = "" - const val version = "1.0.0-alpha10" - - const val animation = "androidx.compose.animation:animation:$version" - const val foundation = "androidx.compose.foundation:foundation:$version" - const val layout = "androidx.compose.foundation:foundation-layout:$version" - const val iconsExtended = "androidx.compose.material:material-icons-extended:$version" - const val material = "androidx.compose.material:material:$version" - const val runtime = "androidx.compose.runtime:runtime:$version" - const val tooling = "androidx.compose.ui:ui-tooling:$version" - const val ui = "androidx.compose.ui:ui:$version" - const val uiUtil = "androidx.compose.ui:ui-util:$version" - const val uiTest = "androidx.compose.ui:ui-test-junit4:$version" - } - - object Test { - private const val version = "1.2.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - } -} diff --git a/Owl/debug.keystore b/Owl/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Owl/debug.keystore and /dev/null differ diff --git a/Owl/gradle.properties b/Owl/gradle.properties deleted file mode 100644 index b2d834ce9c..0000000000 --- a/Owl/gradle.properties +++ /dev/null @@ -1,42 +0,0 @@ -# -# Copyright 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m - -# Turn on parallel compilation, caching and on-demand configuration -org.gradle.configureondemand=true -org.gradle.caching=true -org.gradle.parallel=true - -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true - -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Owl/gradle/wrapper/gradle-wrapper.properties b/Owl/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 089b9f390f..0000000000 --- a/Owl/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-rc-1-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/Owl/screenshots/course_details.gif b/Owl/screenshots/course_details.gif deleted file mode 100644 index a3f943e365..0000000000 Binary files a/Owl/screenshots/course_details.gif and /dev/null differ diff --git a/Owl/screenshots/courses.gif b/Owl/screenshots/courses.gif deleted file mode 100644 index de067e8422..0000000000 Binary files a/Owl/screenshots/courses.gif and /dev/null differ diff --git a/Owl/screenshots/onboarding.gif b/Owl/screenshots/onboarding.gif deleted file mode 100644 index ea462f4dbd..0000000000 Binary files a/Owl/screenshots/onboarding.gif and /dev/null differ diff --git a/Owl/screenshots/owl.gif b/Owl/screenshots/owl.gif deleted file mode 100644 index 5c05d4093b..0000000000 Binary files a/Owl/screenshots/owl.gif and /dev/null differ diff --git a/Owl/screenshots/themes.png b/Owl/screenshots/themes.png deleted file mode 100644 index 2f72d2d548..0000000000 Binary files a/Owl/screenshots/themes.png and /dev/null differ diff --git a/Owl/settings.gradle b/Owl/settings.gradle deleted file mode 100644 index e7b4def49c..0000000000 --- a/Owl/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -include ':app' diff --git a/Owl/spotless/copyright.kt b/Owl/spotless/copyright.kt deleted file mode 100644 index 806db0fb54..0000000000 --- a/Owl/spotless/copyright.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright $YEAR The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - diff --git a/README.md b/README.md index 89d0f9bb28..0c4431e726 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Jetpack Compose Samples -<img src="readme/samples_montage.gif" alt="Jetpack Compose Samples" width="1024" /> +<img src="readme/samples_montage.gif" alt="Jetpack Compose Samples" width="824" /> This repository contains a set of individual Android Studio projects to help you learn about Compose in Android. Each sample demonstrates different use cases, complexity levels and APIs. -For more information, please [read the documentation](https://developer.android.com/jetpack/compose) +For more information, please [read the documentation](https://developer.android.com/jetpack/compose). 💻 Requirements ------------ -To try out these sample apps, you need to use the latest [Canary version of Android Studio](https://developer.android.com/studio/preview). +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). @@ -20,30 +20,92 @@ project from Android Studio following the steps |:-----|---------| | <br><img src="readme/jetnews.png" alt="JetNews" width="240"></img> <br><br> A sample blog post viewer that demonstrates the use of Compose with a typical Material app and real-world architecture. <br><br> • Medium complexity<br>• Varied UI<br>• Light & dark themes<br>• Resource loading<br>• UI Testing <br><br> **[> Browse](JetNews/)**<br><br> | <img src="readme/screenshots/JetNews.png" width="320" alt="Jetnews sample demo"> | | | | -| <br><img src="readme/jetchat.png" alt="Jetchat" width="240"></img> <br><br>A sample chat app that focuses on UI state patterns and text input.<br><br>• Low complexity<br>• Simple Material Design theme (Light & dark)<br>• Resource loading<br>• Back button handling<br>• Integration with Architecture Components: Navigation, Fragments, LiveData, ViewModel<br>• Animation<br>• UI Testing<br><br>**[> Browse](Jetchat/)** <br><br> | <img src="readme/screenshots/Jetchat.png" width="320" alt="Jetchat sample demo">| -| | | -| <br><img src="readme/jetsurvey.png" alt="Jetsurvey" width="240"></img> <br><br>A sample survey app that showcases text input, validation and UI state management in Compose.<br><br>• Low complexity<br>• `TextField` and form validation<br>• Snackbar implementation<br>• Element reusability and styling<br>• Various form elements<br><br><br>**[> Browse](Jetsurvey/)** <br><br> | <img src="readme/screenshots/Jetsurvey.png" width="320" alt="Jetsurvey sample demo"> | +| <br><img src="readme/jetchat.png" alt="Jetchat" width="240"></img> <br><br>A sample chat app that focuses on UI state patterns and text input.<br><br>• Low complexity<br>• Material Design 3 theme and Material You dynamic color<br>• Resource loading<br>• Back button handling<br>• Integration with Architecture Components: Navigation, Fragments, LiveData, ViewModel<br>• Animation<br>• UI Testing<br><br>**[> Browse](Jetchat/)** <br><br> | <img src="readme/screenshots/Jetchat.png" width="320" alt="Jetchat sample demo">| | | | | <br><img src="readme/jetsnack.png" alt="Jetsnack" width="240"></img> <br><br>Jetsnack is a sample snack ordering app built with Compose.<br><br>• Medium complexity<br>• Custom design system<br>• Custom layouts<br>• Animation<br><br>**[> Browse](Jetsnack/)** <br><br> | <img src="readme/screenshots/Jetsnack.png" width="320" alt="Jetsnack sample demo">| | | | | <br><img src="readme/jetcaster.png" alt="Jetcaster" width="240"></img> <br><br>A sample podcast app that features a full-featured, Redux-style architecture and showcases dynamic themes.<br><br>• Advanced sample<br>• Dynamic theming using podcast artwork<br>• Image fetching<br>• [`WindowInsets`](https://developer.android.com/reference/kotlin/android/view/WindowInsets) support<br>• Coroutines<br>• Local storage with Room<br><br>**[> Browse](Jetcaster/)** <br><br> | <img src="readme/screenshots/Jetcaster.png" width="320" alt="Jetcaster sample demo">| | | | -| <br><img src="readme/rally.png" alt="Rally" width="240"></img> <br><br>A Compose implementation of the Rally Material study, a financial app that focuses on data, charts, reusability and animations.<br><br>• Low complexity<br>• Material theming with a dark-only theme<br>• Custom layouts and reusable elements<br>• Charts and tables<br>• Animations<br>• Screenshot tests<br><br>**[> Browse](Rally/)** <br><br> | <img src="readme/screenshots/Rally.png" width="320" alt="Rally sample demo">| -| | | -| <br><img src="readme/crane.png" alt="Crane" width="240"></img> <br><br>A Compose implementation of the Crane Material study, a travel app that uses Material Design components and Material Theming to create a personalized, on-brand experience.<br><br>• Medium complexity<br>• Draggable UI elements<br>• Android Views inside Compose<br>• UI state handling<br>• UI Tests<br><br>**[> Browse](Crane/)** <br><br> | <img src="readme/screenshots/Crane.png" width="320" alt="Crane sample demo">| +| <br><img src="readme/reply.png" alt="Reply" width="240"></img> <br><br>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.<br><br>• Medium complexity<br>• Adaptive UI for phones, tablet and desktops<br>• Foldable support<br>• Material 3 theming & Components<br>• Dynamic colors and Light/Dark theme support<br><br>**[> Browse](Reply/)** <br><br> | <img src="readme/screenshots/Reply.png" width="320" alt="Reply sample demo">| | | | -| <br><img src="readme/owl.png" alt="Owl" width="240"></img> <br><br>A Compose implementation of the Owl Material study. The Owl brand uses bold color, shape, and typography to express its brand attributes: energy, daring, and fun.<br><br>• Medium complexity<br>• Material theming & light/dark themes<br>• Custom layout<br>• Animation<br><br>**[> Browse](Owl/)** <br><br> | <img src="readme/screenshots/Owl.png" width="320" alt="Owl sample demo">| +| <br><img src="readme/jetlagged_heading.png" alt="JetLagged" width="240"></img> <br><br>A sample sleep tracker app, showcasing how to create custom layouts and graphics in Compose<br><br>• Custom Layouts<br>• Graphs with Paths<br><br>**[> Browse](JetLagged/)** <br><br> | <img src="JetLagged/screenshots/JetLagged_Full.png" width="320" alt="JetLagged sample demo">| -🧬 Compose in existing app samples +🧬 Additional samples ------------ -To see how Compose and view-based UIs can coexist and interact together, check out these samples: -* [Sunflower's `compose` branch](https://goo.gle/sunflower-compose) -* [Tivi](https://tivi.app) +| Project | | +|:-----|---------| +| <br><img src="readme/nia.png" alt="Now in Android" width="240"></img> <br><br>An app for keeping up to date with the latest news and developments in Android.<br><br>• [Jetpack Compose](https://developer.android.com/jetpack/compose) first app.<br>• Implements the recommended Android [Architecture Guidelines](https://developer.android.com/topic/architecture) <br>• Integrates [Jetpack Libraries](https://developer.android.com/jetpack) holistically in the context of a real world app<br><br><a href="https://play.google.com/store/apps/details?id=com.google.samples.apps.nowinandroid"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" height="70"></a><br>**[> Browse](https://github.com/android/nowinandroid)** <br><br> | <img src="readme/screenshots/NiA.png" width="320" alt="Now In Android Github Repository">| +| | | +| <br><img src="readme/material_catalog.png" alt="Material Catalog" width="240"></img> <br><br>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.<br><br>• Lives in AOSP—always up to date<br>• Uses the same samples as API reference docs<br>• Theme picker to change Material Theming values at runtime<br>• Links to guidelines, docs, source code, and issue tracker<br><br><a href="https://play.google.com/store/apps/details?id=androidx.compose.material.catalog"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" height="70"></a><br>**[> Browse on AOSP](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/integration-tests/material-catalog)** <br><br> | <img src="readme/screenshots/Material_Catalog.png" width="320" alt="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 2020 The Android Open Source Project +Copyright 2024 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Rally/.gitignore b/Rally/.gitignore deleted file mode 100644 index aa724b7707..0000000000 --- a/Rally/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -*.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 diff --git a/Rally/.google/packaging.yaml b/Rally/.google/packaging.yaml deleted file mode 100644 index 84aff54914..0000000000 --- a/Rally/.google/packaging.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (C) 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# GOOGLE SAMPLE PACKAGING DATA -# -# This file is used by Google as part of our samples packaging process. -# End users may safely ignore this file. It has no relevance to other systems. ---- -status: PUBLISHED -technologies: [Android] -categories: [Compose] -languages: [Kotlin] -solutions: [Mobile] -github: android/compose-samples -level: BEGINNER -apiRefs: - - android:androidx.compose.Composable -license: apache2 diff --git a/Rally/README.md b/Rally/README.md deleted file mode 100644 index 2e14956646..0000000000 --- a/Rally/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# Rally sample - -This sample is a [Jetpack Compose][compose] implementation of [Rally][rally], a Material Design study. - -To try out these sample apps, you need to use the latest Canary version of Android Studio 4.2. -You can clone this repository or import the -project from Android Studio following the steps -[here](https://developer.android.com/jetpack/compose/setup#sample). - -This sample showcases: - -* [Material theming][materialtheming] -* Custom layouts and reusable elements -* Charts and tables -* Animations - -<img src="screenshots/rally.gif"/> - -### Status: 🚧 In progress - -This sample is still in under development, and some features are not yet implemented. - -## Features - -### Purpose -This sample is a simple introduction to Material Design in Compose and it focuses on creating custom layouts and reusable elements. It uses a very simple architecture with a single activity and some hard-coded sample data. The navigation mechanism is implemented as a placeholder for an eventual official implementation using [Android Architecture Components Navigation](https://developer.android.com/guide/navigation). - -### Custom layouts and reusable elements -Rally contains screens that have very similar elements, which allows for reusing a lot of code and implementing composables that are "styled" programmatically as needed. - -For example, [AccountsScreen](app/src/main/java/com/example/compose/rally/ui/accounts/AccountsScreen.kt) and [BillsScreen](app/src/main/java/com/example/compose/rally/ui/bills/BillsScreen.kt) wrap the same [`StatementBody`](app/src/main/java/com/example/compose/rally/ui/components/DetailsScreen.kt) composable which takes a list of _items_, their colors, amounts and even a slot to compose the item itself. Instead of passing lists with all this meta information, it's much more convenient, reusable and performant to pass functions and delegate how to fetch this information to each caller, making `StatementBody` completely generic: - -```kotlin -@Composable -fun <T> StatementBody( - items: List<T>, - colors: (T) -> Color, - amounts: (T) -> Float, - rows: @Composable (T) -> Unit - ... -``` - -```kotlin -@Composable -fun AccountsBody(accounts: List<Account>) { - StatementBody( - items = accounts, - colors = { account -> account.color }, - amounts = { account -> account.balance }, - rows = { account -> AccountRow(...) } - ... -``` - -### Theming -Rally follows [Material Design][materialtheming], customizing [colors](app/src/main/java/com/example/compose/rally/ui/theme/Color.kt) and [typography](app/src/main/java/com/example/compose/rally/ui/theme/RallyTheme.kt) used in the app via the [RallyTheme](app/src/main/java/com/example/compose/rally/ui/theme/RallyTheme.kt). Rally's design only contains a dark theme, therefore the theme does not contain any light colors. - -### Charts and animations -This sample features a donut chart that combines drawing using [`Canvas`](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/Canvas) with animations combining two animated parameters: `AngleOffset` and `Shift`. This creates the animation with minimum boilerplate: - -```kotlin -private enum class AnimatedCircleProgress { START, END } - -private val CircularTransition = transitionDefinition<AnimatedCircleProgress> { - state(AnimatedCircleProgress.START) { - this[AngleOffset] = 0f - this[Shift] = 0f - } - state(AnimatedCircleProgress.END) { - this[AngleOffset] = 360f - this[Shift] = 30f - } - transition(fromState = AnimatedCircleProgress.START, toState = AnimatedCircleProgress.END) { - AngleOffset using tween( - delayMillis = 500, - durationMillis = 900, - easing = CubicBezierEasing(0f, 0.75f, 0.35f, 0.85f) - ) - Shift using tween( - delayMillis = 500, - durationMillis = 900, - easing = LinearOutSlowInEasing - ) - } -} -``` - -<img src="screenshots/donut.gif"/> - -## 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 -[rally]: https://material.io/design/material-studies/rally.html -[materialtheming]: https://material.io/design/material-theming/overview.html#material-theming diff --git a/Rally/app/build.gradle b/Rally/app/build.gradle deleted file mode 100644 index 81e6e6cb51..0000000000 --- a/Rally/app/build.gradle +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.compose.rally.buildsrc.Libs - -plugins { - id 'com.android.application' - id 'kotlin-android' -} - -android { - compileSdkVersion 30 - - defaultConfig { - applicationId "com.example.compose.rally" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 1 - versionName '1.0' - testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' - - vectorDrawables.useSupportLibrary = true - } - - signingConfigs { - // We use a bundled debug keystore, to allow debug builds from CI to be upgradable - debug { - storeFile rootProject.file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } - } - - buildTypes { - debug { - signingConfig signingConfigs.debug - } - - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - buildFeatures { - compose true - - // Disable unused AGP features - buildConfig false - aidl false - renderScript false - resValues false - shaders false - } - - composeOptions { - kotlinCompilerVersion Libs.Kotlin.version - kotlinCompilerExtensionVersion Libs.AndroidX.Compose.version - } - - packagingOptions { - exclude "META-INF/licenses/**" - exclude "META-INF/AL2.0" - exclude "META-INF/LGPL2.1" - } -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.android - - implementation Libs.AndroidX.coreKtx - implementation Libs.AndroidX.appcompat - implementation Libs.AndroidX.Navigation.fragment - implementation Libs.AndroidX.Navigation.uiKtx - implementation Libs.material - - implementation Libs.AndroidX.Compose.layout - implementation Libs.AndroidX.Compose.material - implementation Libs.AndroidX.Compose.materialIconsExtended - implementation Libs.AndroidX.Compose.tooling - implementation Libs.AndroidX.Compose.runtime - implementation Libs.AndroidX.Compose.runtimeLivedata - - androidTestImplementation Libs.junit - androidTestImplementation Libs.AndroidX.Test.core - androidTestImplementation Libs.AndroidX.Test.espressoCore - androidTestImplementation Libs.AndroidX.Test.rules - androidTestImplementation Libs.AndroidX.Test.Ext.junit - androidTestImplementation Libs.AndroidX.Compose.test - androidTestImplementation Libs.AndroidX.Compose.uiTest -} diff --git a/Rally/app/proguard-rules.pro b/Rally/app/proguard-rules.pro deleted file mode 100644 index 4cb94585a0..0000000000 --- a/Rally/app/proguard-rules.pro +++ /dev/null @@ -1,24 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. --keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. --renamesourcefileattribute SourceFile - -# Repackage classes into the top-level. --repackageclasses diff --git a/Rally/app/src/androidTest/assets/circle_100.png b/Rally/app/src/androidTest/assets/circle_100.png deleted file mode 100644 index 22d19ae60b..0000000000 Binary files a/Rally/app/src/androidTest/assets/circle_100.png and /dev/null differ diff --git a/Rally/app/src/androidTest/assets/circle_done.png b/Rally/app/src/androidTest/assets/circle_done.png deleted file mode 100644 index 21e466b036..0000000000 Binary files a/Rally/app/src/androidTest/assets/circle_done.png and /dev/null differ diff --git a/Rally/app/src/androidTest/assets/circle_initial.png b/Rally/app/src/androidTest/assets/circle_initial.png deleted file mode 100644 index b18019a817..0000000000 Binary files a/Rally/app/src/androidTest/assets/circle_initial.png and /dev/null differ diff --git a/Rally/app/src/androidTest/java/com/example/compose/rally/AnimatingCircleTests.kt b/Rally/app/src/androidTest/java/com/example/compose/rally/AnimatingCircleTests.kt deleted file mode 100644 index d0c49fe38c..0000000000 --- a/Rally/app/src/androidTest/java/com/example/compose/rally/AnimatingCircleTests.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally - -import android.os.Build -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.unit.dp -import androidx.test.filters.SdkSuppress -import com.example.compose.rally.ui.components.AnimatedCircle -import com.example.compose.rally.ui.theme.RallyTheme -import org.junit.Rule -import org.junit.Test - -/** - * Test to showcase [AnimationClockTestRule] present in [ComposeTestRule]. It allows for animation - * testing at specific points in time. - * - * For assertions, a simple screenshot testing framework is used. It requires SDK 26+ and to - * be run on a device with 420dpi, as that the density used to generate the golden images - * present in androidTest/assets. It runs bitmap comparisons on device. - * - * Note that different systems can produce slightly different screenshots making the test fail. - */ -@ExperimentalTestApi -@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) -class AnimatingCircleTests { - - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun circleAnimation_idle_screenshot() { - showAnimatedCircle() - assertScreenshotMatchesGolden("circle_done", composeTestRule.onRoot()) - } - - @Test - fun circleAnimation_initial_screenshot() { - compareTimeScreenshot(0, "circle_initial") - } - - @Test - fun circleAnimation_beforeDelay_screenshot() { - compareTimeScreenshot(499, "circle_initial") - } - - @Test - fun circleAnimation_midAnimation_screenshot() { - compareTimeScreenshot(600, "circle_100") - } - - @Test - fun circleAnimation_animationDone_screenshot() { - compareTimeScreenshot(1400, "circle_done") - } - - private fun compareTimeScreenshot(timeMs: Long, goldenName: String) { - // Start with a paused clock - composeTestRule.clockTestRule.pauseClock() - - // Start the unit under test - showAnimatedCircle() - - // Advance clock (keeping it paused) - composeTestRule.clockTestRule.advanceClock(timeMs) - - // Take screenshot and compare with golden image in androidTest/assets - assertScreenshotMatchesGolden(goldenName, composeTestRule.onRoot()) - } - - private fun showAnimatedCircle() { - composeTestRule.setContent { - RallyTheme { - AnimatedCircle( - modifier = Modifier.background(Color.White).preferredSize(320.dp), - proportions = listOf(0.25f, 0.5f, 0.25f), - colors = listOf(Color.Red, Color.DarkGray, Color.Black) - ) - } - } - } -} diff --git a/Rally/app/src/androidTest/java/com/example/compose/rally/ScreenshotComparator.kt b/Rally/app/src/androidTest/java/com/example/compose/rally/ScreenshotComparator.kt deleted file mode 100644 index 3ea0642ed7..0000000000 --- a/Rally/app/src/androidTest/java/com/example/compose/rally/ScreenshotComparator.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.compose.ui.test.SemanticsNodeInteraction -import androidx.compose.ui.test.captureToImage -import androidx.test.platform.app.InstrumentationRegistry -import java.io.FileOutputStream - -/** - * Simple on-device screenshot comparator that uses golden images present in - * `androidTest/assets`. It's used to showcase the [AnimationClockTestRule] used in - * [AnimatingCircleTests]. - * - * Minimum SDK is O. Densities between devices must match. - * - * Screenshots are saved on device in `/data/data/{package}/files`. - */ -@RequiresApi(Build.VERSION_CODES.O) -fun assertScreenshotMatchesGolden( - goldenName: String, - node: SemanticsNodeInteraction -) { - val bitmap = node.captureToImage().asAndroidBitmap() - - // Save screenshot to file for debugging - saveScreenshot(goldenName + System.currentTimeMillis().toString(), bitmap) - val golden = InstrumentationRegistry.getInstrumentation() - .context.resources.assets.open("$goldenName.png").use { BitmapFactory.decodeStream(it) } - - golden.compare(bitmap) -} - -private fun saveScreenshot(filename: String, bmp: Bitmap) { - val path = InstrumentationRegistry.getInstrumentation().targetContext.filesDir.canonicalPath - FileOutputStream("$path/$filename.png").use { out -> - bmp.compress(Bitmap.CompressFormat.PNG, 100, out) - } - println("Saved screenshot to $path/$filename.png") -} - -private fun Bitmap.compare(other: Bitmap) { - if (this.width != other.width || this.height != other.height) { - throw AssertionError("Size of screenshot does not match golden file (check device density)") - } - // Compare row by row to save memory on device - val row1 = IntArray(width) - val row2 = IntArray(width) - for (column in 0 until height) { - // Read one row per bitmap and compare - this.getRow(row1, column) - other.getRow(row2, column) - if (!row1.contentEquals(row2)) { - throw AssertionError("Sizes match but bitmap content has differences") - } - } -} - -private fun Bitmap.getRow(pixels: IntArray, column: Int) { - this.getPixels(pixels, 0, width, 0, column, width, 1) -} diff --git a/Rally/app/src/debug/AndroidManifest.xml b/Rally/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 5b19967725..0000000000 --- a/Rally/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,26 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - --> - -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.example.compose.rally"> - <!-- Needed for UI Tests using ComposeTestRule without an existing activity - https://issuetracker.google.com/162419586 --> - <application> - <activity - android:name="androidx.activity.ComponentActivity" /> - </application> -</manifest> diff --git a/Rally/app/src/main/AndroidManifest.xml b/Rally/app/src/main/AndroidManifest.xml deleted file mode 100644 index 1c6aa714a2..0000000000 --- a/Rally/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,38 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - --> - -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - package="com.example.compose.rally"> - - <application - android:allowBackup="true" - android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" - android:supportsRtl="true" - android:theme="@style/Theme.Rally"> - <activity - android:name=".RallyActivity" - android:windowSoftInputMode="adjustResize" - android:label="@string/app_name"> - <intent-filter> - <action android:name="android.intent.action.MAIN" /> - - <category android:name="android.intent.category.LAUNCHER" /> - </intent-filter> - </activity> - </application> -</manifest> diff --git a/Rally/app/src/main/java/com/example/compose/rally/RallyActivity.kt b/Rally/app/src/main/java/com/example/compose/rally/RallyActivity.kt deleted file mode 100644 index 53c7b089bf..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/RallyActivity.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.savedinstancestate.savedInstanceState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.setContent -import com.example.compose.rally.ui.components.RallyTopAppBar -import com.example.compose.rally.ui.theme.RallyTheme - -/** - * This Activity recreates part of the Rally Material Study from - * https://material.io/design/material-studies/rally.html - */ -class RallyActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - RallyApp() - } - } -} - -@Composable -fun RallyApp() { - RallyTheme { - val allScreens = RallyScreen.values().toList() - var currentScreen by savedInstanceState { RallyScreen.Overview } - Scaffold( - topBar = { - RallyTopAppBar( - allScreens = allScreens, - onTabSelected = { screen -> currentScreen = screen }, - currentScreen = currentScreen - ) - } - ) { innerPadding -> - Box(Modifier.padding(innerPadding)) { - currentScreen.content(onScreenChange = { screen -> currentScreen = screen }) - } - } - } -} diff --git a/Rally/app/src/main/java/com/example/compose/rally/RallyScreen.kt b/Rally/app/src/main/java/com/example/compose/rally/RallyScreen.kt deleted file mode 100644 index 3bf4ba8bfb..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/RallyScreen.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AttachMoney -import androidx.compose.material.icons.filled.MoneyOff -import androidx.compose.material.icons.filled.PieChart -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.vector.ImageVector -import com.example.compose.rally.data.UserData -import com.example.compose.rally.ui.accounts.AccountsBody -import com.example.compose.rally.ui.bills.BillsBody -import com.example.compose.rally.ui.overview.OverviewBody - -/** - * Screen state for Rally. Navigation is kept simple until a proper mechanism is available. Back - * navigation is not supported. - */ -enum class RallyScreen( - val icon: ImageVector, - private val body: @Composable ((RallyScreen) -> Unit) -> Unit -) { - Overview( - icon = Icons.Filled.PieChart, - body = { onScreenChange -> OverviewBody(onScreenChange) } - ), - Accounts( - icon = Icons.Filled.AttachMoney, - body = { AccountsBody(UserData.accounts) } - ), - Bills( - icon = Icons.Filled.MoneyOff, - body = { BillsBody(UserData.bills) } - ); - - @Composable - fun content(onScreenChange: (RallyScreen) -> Unit) { - body(onScreenChange) - } -} diff --git a/Rally/app/src/main/java/com/example/compose/rally/data/RallyData.kt b/Rally/app/src/main/java/com/example/compose/rally/data/RallyData.kt deleted file mode 100644 index ba671042d0..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/data/RallyData.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.data - -import androidx.compose.runtime.Immutable -import androidx.compose.ui.graphics.Color - -/* Hard-coded data for the Rally sample. */ - -@Immutable -data class Account( - val name: String, - val number: Int, - val balance: Float, - val color: Color -) - -@Immutable -data class Bill( - val name: String, - val due: String, - val amount: Float, - val color: Color -) - -object UserData { - val accounts: List<Account> = listOf( - Account( - "Checking", - 1234, - 2215.13f, - Color(0xFF004940) - ), - Account( - "Home Savings", - 5678, - 8676.88f, - Color(0xFF005D57) - ), - Account( - "Car Savings", - 9012, - 987.48f, - Color(0xFF04B97F) - ), - Account( - "Vacation", - 3456, - 253f, - Color(0xFF37EFBA) - ) - ) - val bills: List<Bill> = listOf( - Bill( - "RedPay Credit", - "Jan 29", - 45.36f, - Color(0xFFFFDC78) - ), - Bill( - "Rent", - "Feb 9", - 1200f, - Color(0xFFFF6951) - ), - Bill( - "TabFine Credit", - "Feb 22", - 87.33f, - Color(0xFFFFD7D0) - ), - Bill( - "ABC Loans", - "Feb 29", - 400f, - Color(0xFFFFAC12) - ), - Bill( - "ABC Loans 2", - "Feb 29", - 77.4f, - Color(0xFFFFAC12) - ) - ) -} diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/accounts/AccountsScreen.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/accounts/AccountsScreen.kt deleted file mode 100644 index c3d633066c..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/accounts/AccountsScreen.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.ui.accounts - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.example.compose.rally.R -import com.example.compose.rally.data.Account -import com.example.compose.rally.ui.components.AccountRow -import com.example.compose.rally.ui.components.StatementBody - -/** - * The Accounts screen. - */ -@Composable -fun AccountsBody(accounts: List<Account>) { - StatementBody( - items = accounts, - amounts = { account -> account.balance }, - colors = { account -> account.color }, - amountsTotal = accounts.map { account -> account.balance }.sum(), - circleLabel = stringResource(R.string.total), - rows = { account -> - AccountRow( - name = account.name, - number = account.number, - amount = account.balance, - color = account.color - ) - } - ) -} diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/bills/BillsScreen.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/bills/BillsScreen.kt deleted file mode 100644 index 49f6d6e483..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/bills/BillsScreen.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.ui.bills - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.example.compose.rally.R -import com.example.compose.rally.data.Bill -import com.example.compose.rally.ui.components.BillRow -import com.example.compose.rally.ui.components.StatementBody - -/** - * The Bills screen. - */ -@Composable -fun BillsBody(bills: List<Bill>) { - StatementBody( - items = bills, - amounts = { bill -> bill.amount }, - colors = { bill -> bill.color }, - amountsTotal = bills.map { bill -> bill.amount }.sum(), - circleLabel = stringResource(R.string.due), - rows = { bill -> - BillRow(bill.name, bill.due, bill.amount, bill.color) - } - ) -} diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/components/CommonUi.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/components/CommonUi.kt deleted file mode 100644 index 35102c07ca..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/components/CommonUi.kt +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.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.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredSize -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.material.AmbientContentAlpha -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Providers -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.example.compose.rally.R -import java.text.DecimalFormat - -/** - * A row representing the basic information of an Account. - */ -@Composable -fun AccountRow(name: String, number: Int, amount: Float, color: Color) { - BaseRow( - color = color, - title = name, - subtitle = stringResource(R.string.account_redacted) + AccountDecimalFormat.format(number), - amount = amount, - negative = false - ) -} - -/** - * A row representing the basic information of a Bill. - */ -@Composable -fun BillRow(name: String, due: String, amount: Float, color: Color) { - BaseRow( - color = color, - title = name, - subtitle = "Due $due", - amount = amount, - negative = true - ) -} - -@Composable -private fun BaseRow( - color: Color, - title: String, - subtitle: String, - amount: Float, - negative: Boolean -) { - Row( - modifier = Modifier.preferredHeight(68.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val typography = MaterialTheme.typography - AccountIndicator( - color = color, - modifier = Modifier - ) - Spacer(Modifier.preferredWidth(12.dp)) - Column(Modifier) { - Text(text = title, style = typography.body1) - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Text(text = subtitle, style = typography.subtitle1) - } - } - Spacer(Modifier.weight(1f)) - Row( - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = if (negative) "–$ " else "$ ", - style = typography.h6, - modifier = Modifier.align(Alignment.CenterVertically) - ) - Text( - text = formatAmount( - amount - ), - style = typography.h6, - modifier = Modifier.align(Alignment.CenterVertically) - ) - } - Spacer(Modifier.preferredWidth(16.dp)) - - Providers(AmbientContentAlpha provides ContentAlpha.medium) { - Icon( - imageVector = Icons.Filled.ChevronRight, - modifier = Modifier - .padding(end = 12.dp) - .preferredSize(24.dp) - ) - } - } - RallyDivider() -} - -/** - * A vertical colored line that is used in a [BaseRow] to differentiate accounts. - */ -@Composable -private fun AccountIndicator(color: Color, modifier: Modifier = Modifier) { - Spacer(modifier.preferredSize(4.dp, 36.dp).background(color = color)) -} - -@Composable -fun RallyDivider(modifier: Modifier = Modifier) { - Divider(color = MaterialTheme.colors.background, thickness = 1.dp, modifier = modifier) -} - -fun formatAmount(amount: Float): String { - return AmountDecimalFormat.format(amount) -} - -private val AccountDecimalFormat = DecimalFormat("####") -private val AmountDecimalFormat = DecimalFormat("#,###.##") - -/** - * Used with accounts and bills to create the animated circle. - */ -fun <E> List<E>.extractProportions(selector: (E) -> Float): List<Float> { - val total = this.sumByDouble { selector(it).toDouble() } - return this.map { (selector(it) / total).toFloat() } -} diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/components/DetailsScreen.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/components/DetailsScreen.kt deleted file mode 100644 index 7b2cdec146..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/components/DetailsScreen.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.ui.components - -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.material.Card -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -/** - * Generic component used by the accounts and bills screens to show a chart and a list of items. - */ -@Composable -fun <T> StatementBody( - items: List<T>, - colors: (T) -> Color, - amounts: (T) -> Float, - amountsTotal: Float, - circleLabel: String, - rows: @Composable (T) -> Unit -) { - ScrollableColumn { - Box(Modifier.padding(16.dp)) { - val accountsProportion = items.extractProportions { amounts(it) } - val circleColors = items.map { colors(it) } - AnimatedCircle( - accountsProportion, - circleColors, - Modifier.preferredHeight(300.dp).align(Alignment.Center).fillMaxWidth() - ) - Column(modifier = Modifier.align(Alignment.Center)) { - Text( - text = circleLabel, - style = MaterialTheme.typography.body1, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - Text( - text = formatAmount(amountsTotal), - style = MaterialTheme.typography.h2, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - } - } - Spacer(Modifier.preferredHeight(10.dp)) - Card { - Column(modifier = Modifier.padding(12.dp)) { - items.forEach { item -> - rows(item) - } - } - } - } -} diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/components/RallyAlertDialog.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/components/RallyAlertDialog.kt deleted file mode 100644 index 559a979f73..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/components/RallyAlertDialog.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.ui.components - -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.material.AlertDialog -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.unit.dp -import com.example.compose.rally.ui.theme.RallyDialogThemeOverlay - -@Composable -fun RallyAlertDialog( - onDismiss: () -> Unit, - bodyText: String, - buttonText: String -) { - RallyDialogThemeOverlay { - AlertDialog( - onDismissRequest = onDismiss, - text = { Text(bodyText) }, - buttons = { - Column { - Divider( - Modifier.padding(horizontal = 12.dp), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f) - ) - TextButton( - onClick = onDismiss, - shape = RectangleShape, - contentPadding = PaddingValues(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - Text(buttonText) - } - } - } - ) - } -} diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/components/RallyAnimatedCircle.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/components/RallyAnimatedCircle.kt deleted file mode 100644 index c2fda2b195..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/components/RallyAnimatedCircle.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.ui.components - -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.FloatPropKey -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.transitionDefinition -import androidx.compose.animation.core.tween -import androidx.compose.animation.transition -import androidx.compose.foundation.Canvas -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.AmbientDensity -import androidx.compose.ui.unit.dp - -private const val DividerLengthInDegrees = 1.8f -private val AngleOffset = FloatPropKey("angle") -private val Shift = FloatPropKey("shift") - -/** - * A donut chart that animates when loaded. - */ -@Composable -fun AnimatedCircle( - proportions: List<Float>, - colors: List<Color>, - modifier: Modifier = Modifier -) { - val stroke = with(AmbientDensity.current) { Stroke(5.dp.toPx()) } - val state = transition( - definition = CircularTransition, - initState = AnimatedCircleProgress.START, - toState = AnimatedCircleProgress.END - ) - Canvas(modifier) { - val innerRadius = (size.minDimension - stroke.width) / 2 - val halfSize = size / 2.0f - val topLeft = Offset( - halfSize.width - innerRadius, - halfSize.height - innerRadius - ) - val size = Size(innerRadius * 2, innerRadius * 2) - var startAngle = state[Shift] - 90f - proportions.forEachIndexed { index, proportion -> - val sweep = proportion * state[AngleOffset] - drawArc( - color = colors[index], - startAngle = startAngle + DividerLengthInDegrees / 2, - sweepAngle = sweep - DividerLengthInDegrees, - topLeft = topLeft, - size = size, - useCenter = false, - style = stroke - ) - startAngle += sweep - } - } -} -private enum class AnimatedCircleProgress { START, END } - -private val CircularTransition = transitionDefinition<AnimatedCircleProgress> { - state(AnimatedCircleProgress.START) { - this[AngleOffset] = 0f - this[Shift] = 0f - } - state(AnimatedCircleProgress.END) { - this[AngleOffset] = 360f - this[Shift] = 30f - } - transition(fromState = AnimatedCircleProgress.START, toState = AnimatedCircleProgress.END) { - AngleOffset using tween( - delayMillis = 500, - durationMillis = 900, - easing = CubicBezierEasing(0f, 0.75f, 0.35f, 0.85f) - ) - Shift using tween( - delayMillis = 500, - durationMillis = 900, - easing = LinearOutSlowInEasing - ) - } -} diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/components/TopAppBar.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/components/TopAppBar.kt deleted file mode 100644 index 16bed1e2d3..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/components/TopAppBar.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.ui.components - -import androidx.compose.animation.animateAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.foundation.layout.preferredWidth -import androidx.compose.foundation.selection.selectable -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.example.compose.rally.RallyScreen - -@Composable -fun RallyTopAppBar( - allScreens: List<RallyScreen>, - onTabSelected: (RallyScreen) -> Unit, - currentScreen: RallyScreen -) { - Surface( - Modifier - .preferredHeight(TabHeight) - .fillMaxWidth() - ) { - Row { - allScreens.forEach { screen -> - RallyTab( - text = screen.name.toUpperCase(), - icon = screen.icon, - onSelected = { onTabSelected(screen) }, - selected = currentScreen == screen - ) - } - } - } -} - -@Composable -private fun RallyTab( - text: String, - icon: ImageVector, - onSelected: () -> Unit, - selected: Boolean -) { - val color = MaterialTheme.colors.onSurface - val durationMillis = if (selected) TabFadeInAnimationDuration else TabFadeOutAnimationDuration - val animSpec = remember { - tween<Color>( - durationMillis = durationMillis, - easing = LinearEasing, - delayMillis = TabFadeInAnimationDelay - ) - } - val tabTintColor by animateAsState( - targetValue = if (selected) color else color.copy(alpha = InactiveTabOpacity), - animationSpec = animSpec - ) - Row( - modifier = Modifier - .padding(16.dp) - .animateContentSize() - .preferredHeight(TabHeight) - .selectable( - selected = selected, - onClick = onSelected, - indication = rememberRipple( - bounded = false, - radius = Dp.Unspecified, - color = Color.Unspecified - ) - ) - ) { - Icon(imageVector = icon, tint = tabTintColor) - if (selected) { - Spacer(Modifier.preferredWidth(12.dp)) - Text(text, color = tabTintColor) - } - } -} - -private val TabHeight = 56.dp -private const val InactiveTabOpacity = 0.60f - -private const val TabFadeInAnimationDuration = 150 -private const val TabFadeInAnimationDelay = 100 -private const val TabFadeOutAnimationDuration = 100 diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/overview/OverviewScreen.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/overview/OverviewScreen.kt deleted file mode 100644 index de60bb0839..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/overview/OverviewScreen.kt +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.ui.overview - -import androidx.compose.foundation.ScrollableColumn -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.preferredHeight -import androidx.compose.material.Card -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Sort -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.example.compose.rally.R -import com.example.compose.rally.RallyScreen -import com.example.compose.rally.data.UserData -import com.example.compose.rally.ui.components.AccountRow -import com.example.compose.rally.ui.components.BillRow -import com.example.compose.rally.ui.components.RallyAlertDialog -import com.example.compose.rally.ui.components.RallyDivider -import com.example.compose.rally.ui.components.formatAmount - -@Composable -fun OverviewBody(onScreenChange: (RallyScreen) -> Unit = {}) { - ScrollableColumn(contentPadding = PaddingValues(16.dp)) { - AlertCard() - Spacer(Modifier.preferredHeight(RallyDefaultPadding)) - AccountsCard(onScreenChange) - Spacer(Modifier.preferredHeight(RallyDefaultPadding)) - BillsCard(onScreenChange) - } -} - -/** - * The Alerts card within the Rally Overview screen. - */ -@Composable -private fun AlertCard() { - var showDialog by remember { mutableStateOf(false) } - val alertMessage = "Heads up, you've used up 90% of your Shopping budget for this month." - - if (showDialog) { - RallyAlertDialog( - onDismiss = { - showDialog = false - }, - bodyText = alertMessage, - buttonText = "Dismiss".toUpperCase() - ) - } - Card { - Column { - AlertHeader { - showDialog = true - } - RallyDivider( - modifier = Modifier.padding(start = RallyDefaultPadding, end = RallyDefaultPadding) - ) - AlertItem(alertMessage) - } - } -} - -@Composable -private fun AlertHeader(onClickSeeAll: () -> Unit) { - Row( - modifier = Modifier.padding(RallyDefaultPadding).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "Alerts", - style = MaterialTheme.typography.subtitle2, - modifier = Modifier.align(Alignment.CenterVertically) - ) - TextButton( - onClick = onClickSeeAll, - contentPadding = PaddingValues(0.dp), - modifier = Modifier.align(Alignment.CenterVertically) - ) { - Text( - text = "SEE ALL", - style = MaterialTheme.typography.button, - ) - } - } -} - -@Composable -private fun AlertItem(message: String) { - Row( - modifier = Modifier.padding(RallyDefaultPadding), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - style = MaterialTheme.typography.body2, - modifier = Modifier.weight(1f), - text = message - ) - IconButton( - onClick = {}, - modifier = Modifier.align(Alignment.Top) - ) { - Icon(Icons.Filled.Sort) - } - } -} - -/** - * Base structure for cards in the Overview screen. - */ -@Composable -private fun <T> OverviewScreenCard( - title: String, - amount: Float, - onClickSeeAll: () -> Unit, - values: (T) -> Float, - colors: (T) -> Color, - data: List<T>, - row: @Composable (T) -> Unit -) { - Card { - Column { - Column(Modifier.padding(RallyDefaultPadding)) { - Text(text = title, style = MaterialTheme.typography.subtitle2) - val amountText = "$" + formatAmount( - amount - ) - Text(text = amountText, style = MaterialTheme.typography.h2) - } - OverViewDivider(data, values, colors) - Column(Modifier.padding(start = 16.dp, top = 4.dp, end = 8.dp)) { - data.take(SHOWN_ITEMS).forEach { row(it) } - SeeAllButton(onClick = onClickSeeAll) - } - } - } -} - -@Composable -private fun <T> OverViewDivider( - data: List<T>, - values: (T) -> Float, - colors: (T) -> Color -) { - Row(Modifier.fillMaxWidth()) { - data.forEach { item: T -> - Spacer( - modifier = Modifier - .weight(values(item)) - .preferredHeight(1.dp) - .background(colors(item)) - ) - } - } -} -/** - * The Accounts card within the Rally Overview screen. - */ -@Composable -private fun AccountsCard(onScreenChange: (RallyScreen) -> Unit) { - val amount = UserData.accounts.map { account -> account.balance }.sum() - OverviewScreenCard( - title = stringResource(R.string.accounts), - amount = amount, - onClickSeeAll = { - onScreenChange(RallyScreen.Accounts) - }, - data = UserData.accounts, - colors = { it.color }, - values = { it.balance } - ) { account -> - AccountRow( - name = account.name, - number = account.number, - amount = account.balance, - color = account.color - ) - } -} -/** - * The Bills card within the Rally Overview screen. - */ -@Composable -private fun BillsCard(onScreenChange: (RallyScreen) -> Unit) { - val amount = UserData.bills.map { bill -> bill.amount }.sum() - OverviewScreenCard( - title = stringResource(R.string.bills), - amount = amount, - onClickSeeAll = { - onScreenChange(RallyScreen.Bills) - }, - data = UserData.bills, - colors = { it.color }, - values = { it.amount } - ) { bill -> - BillRow( - name = bill.name, - due = bill.due, - amount = bill.amount, - color = bill.color - ) - } -} - -@Composable -private fun SeeAllButton(onClick: () -> Unit) { - TextButton( - onClick = onClick, - modifier = Modifier.preferredHeight(44.dp).fillMaxWidth() - ) { - Text(stringResource(R.string.see_all)) - } -} - -private val RallyDefaultPadding = 12.dp - -private const val SHOWN_ITEMS = 3 diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/theme/Color.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/theme/Color.kt deleted file mode 100644 index ca3979039d..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/theme/Color.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.ui.theme - -import androidx.compose.ui.graphics.Color - -val Green500 = Color(0xFF1EB980) -val DarkBlue900 = Color(0xFF26282F) // TODO: Confirm literal name diff --git a/Rally/app/src/main/java/com/example/compose/rally/ui/theme/RallyTheme.kt b/Rally/app/src/main/java/com/example/compose/rally/ui/theme/RallyTheme.kt deleted file mode 100644 index b402b86c36..0000000000 --- a/Rally/app/src/main/java/com/example/compose/rally/ui/theme/RallyTheme.kt +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.ui.theme - -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Typography -import androidx.compose.material.darkColors -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.font.font -import androidx.compose.ui.text.font.fontFamily -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp -import com.example.compose.rally.R - -private val EczarFontFamily = fontFamily( - font(R.font.eczar_regular), - font(R.font.eczar_semibold, FontWeight.SemiBold) -) -private val RobotoCondensed = fontFamily( - font(R.font.robotocondensed_regular), - font(R.font.robotocondensed_light, FontWeight.Light), - font(R.font.robotocondensed_bold, FontWeight.Bold) -) - -/** - * A [MaterialTheme] for Rally. - */ -@Composable -fun RallyTheme(content: @Composable () -> Unit) { - // Rally is always dark themed. - val colors = darkColors( - primary = Green500, - surface = DarkBlue900, - onSurface = Color.White, - background = DarkBlue900, - onBackground = Color.White - ) - - val typography = Typography( - defaultFontFamily = RobotoCondensed, - h1 = TextStyle( - fontWeight = FontWeight.W100, - fontSize = 96.sp, - ), - h2 = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 44.sp, - fontFamily = EczarFontFamily, - letterSpacing = 1.5.sp - ), - h3 = TextStyle( - fontWeight = FontWeight.W400, - fontSize = 14.sp - ), - h4 = TextStyle( - fontWeight = FontWeight.W700, - fontSize = 34.sp - ), - h5 = TextStyle( - fontWeight = FontWeight.W700, - fontSize = 24.sp - ), - h6 = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 18.sp, - lineHeight = 20.sp, - fontFamily = EczarFontFamily, - letterSpacing = 3.sp - ), - subtitle1 = TextStyle( - fontWeight = FontWeight.Light, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 3.sp - ), - subtitle2 = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - letterSpacing = 0.1.em - ), - body1 = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - letterSpacing = 0.1.em - ), - body2 = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.em - ), - button = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - lineHeight = 16.sp, - letterSpacing = 0.2.em - ), - caption = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 12.sp - ), - overline = TextStyle( - fontWeight = FontWeight.W500, - fontSize = 10.sp - ) - ) - MaterialTheme(colors = colors, typography = typography, content = content) -} - -/** - * A theme overlay used for dialogs. - */ -@Composable -fun RallyDialogThemeOverlay(content: @Composable () -> Unit) { - // Rally is always dark themed. - val dialogColors = darkColors( - primary = Color.White, - surface = Color.White.copy(alpha = 0.12f).compositeOver(Color.Black), - onSurface = Color.White - ) - - // Copy the current [Typography] and replace some text styles for this theme. - val currentTypography = MaterialTheme.typography - val dialogTypography = currentTypography.copy( - body2 = currentTypography.body1.copy( - fontWeight = FontWeight.Normal, - fontSize = 20.sp, - lineHeight = 28.sp, - letterSpacing = 1.sp - ), - button = currentTypography.button.copy( - fontWeight = FontWeight.Bold, - letterSpacing = 0.2.em - ) - ) - MaterialTheme(colors = dialogColors, typography = dialogTypography, content = content) -} diff --git a/Rally/app/src/main/res/drawable-v26/ic_launcher_foreground.xml b/Rally/app/src/main/res/drawable-v26/ic_launcher_foreground.xml deleted file mode 100644 index cb9db18615..0000000000 --- a/Rally/app/src/main/res/drawable-v26/ic_launcher_foreground.xml +++ /dev/null @@ -1,72 +0,0 @@ -<!-- - ~ Copyright 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License. - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" - android:width="108dp" - android:height="108dp" - android:viewportWidth="108" - android:viewportHeight="108"> - <group android:scaleX="0.27" - android:scaleY="0.27" - android:translateX="32.4" - android:translateY="32.4"> - <path - android:pathData="M39.924,79.609C39.924,57.549 57.806,39.664 79.868,39.664V3.717C37.953,3.717 3.974,37.693 3.974,79.609V79.609H39.924Z" - android:fillColor="#005D57" - android:fillType="evenOdd"/> - <path - android:pathData="M39.924,79.609H3.974C3.974,121.522 37.953,155.5 79.868,155.5V119.553C57.806,119.553 39.924,101.669 39.924,79.609" - android:fillColor="#007D51" - android:fillType="evenOdd"/> - <path - android:pathData="M155.759,79.609H119.81C119.81,101.669 101.927,119.553 79.868,119.553V155.5C121.781,155.5 155.759,121.522 155.759,79.609" - android:fillColor="#37EFBA" - android:fillType="evenOdd"/> - <group> - <clip-path - android:pathData="M155.759,79.609H119.81C119.81,101.669 101.927,119.553 79.868,119.553V155.5C121.781,155.5 155.759,121.522 155.759,79.609" - android:fillType="evenOdd"/> - <path - android:pathData="M118.987,78.051H161.873V128.734H150.177L118.987,78.051Z" - android:strokeAlpha="0.08" - android:fillType="evenOdd" - android:fillAlpha="0.08"> - <aapt:attr name="android:fillColor"> - <gradient - android:startY="103.923" - android:startX="152.055" - android:endY="75.5372" - android:endX="140.702" - android:type="linear"> - <item android:offset="0" android:color="#02000000"/> - <item android:offset="0.859318" android:color="#DB000000"/> - <item android:offset="1" android:color="#FF000000"/> - </gradient> - </aapt:attr> - </path> - </group> - <group> - <clip-path - android:pathData="M139.93,3.717C139.881,3.717 139.832,3.718 139.783,3.718H79.869V39.665C101.926,39.665 119.81,57.549 119.811,79.609H155.76V43.327V7.046C155.76,5.564 155.558,4.485 155.18,3.717L139.93,3.717V3.717Z" - android:fillType="evenOdd"/> - <path - android:pathData="M119.811,79.609H155.76V19.694C155.76,10.868 148.606,3.717 139.783,3.717H79.866V39.664C101.928,39.664 119.811,57.549 119.811,79.609" - android:fillColor="#1EB980" - android:fillType="evenOdd"/> - </group> - </group> -</vector> diff --git a/Rally/app/src/main/res/font/eczar_regular.ttf b/Rally/app/src/main/res/font/eczar_regular.ttf deleted file mode 100644 index 8eb92d9757..0000000000 Binary files a/Rally/app/src/main/res/font/eczar_regular.ttf and /dev/null differ diff --git a/Rally/app/src/main/res/font/eczar_semibold.ttf b/Rally/app/src/main/res/font/eczar_semibold.ttf deleted file mode 100644 index 2132b24c0d..0000000000 Binary files a/Rally/app/src/main/res/font/eczar_semibold.ttf and /dev/null differ diff --git a/Rally/app/src/main/res/font/robotocondensed_bold.ttf b/Rally/app/src/main/res/font/robotocondensed_bold.ttf deleted file mode 100644 index 7fe31289ce..0000000000 Binary files a/Rally/app/src/main/res/font/robotocondensed_bold.ttf and /dev/null differ diff --git a/Rally/app/src/main/res/font/robotocondensed_light.ttf b/Rally/app/src/main/res/font/robotocondensed_light.ttf deleted file mode 100644 index 43dd8f42be..0000000000 Binary files a/Rally/app/src/main/res/font/robotocondensed_light.ttf and /dev/null differ diff --git a/Rally/app/src/main/res/font/robotocondensed_regular.ttf b/Rally/app/src/main/res/font/robotocondensed_regular.ttf deleted file mode 100644 index 62dd61e5d0..0000000000 Binary files a/Rally/app/src/main/res/font/robotocondensed_regular.ttf and /dev/null differ diff --git a/Rally/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Rally/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2888177d37..0000000000 --- a/Rally/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ 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. - --> -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@color/backgroundColor" /> - <foreground android:drawable="@drawable/ic_launcher_foreground" /> -</adaptive-icon> \ No newline at end of file diff --git a/Rally/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Rally/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 10a5db1438..0000000000 Binary files a/Rally/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/Rally/app/src/main/res/values/colors.xml b/Rally/app/src/main/res/values/colors.xml deleted file mode 100644 index 849c9a8ea0..0000000000 --- a/Rally/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,20 +0,0 @@ -<!-- - ~ Copyright 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License. - ~ - --> -<resources> - <color name="statusBarColor">#EFEFEF</color> - <color name="backgroundColor">#26282F</color> -</resources> diff --git a/Rally/app/src/main/res/values/strings.xml b/Rally/app/src/main/res/values/strings.xml deleted file mode 100644 index 7f7ee86637..0000000000 --- a/Rally/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,25 +0,0 @@ -<!-- - ~ Copyright 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ 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. - --> - -<resources> - <string name="app_name">Rally</string> - <string name="total">Total</string> - <string name="due">Due</string> - <string name="account_redacted">• • • • • </string> - <string name="accounts">Accounts</string> - <string name="bills">Bills</string> - <string name="see_all">SEE ALL</string> -</resources> diff --git a/Rally/app/src/main/res/values/styles.xml b/Rally/app/src/main/res/values/styles.xml deleted file mode 100644 index 5533c76b87..0000000000 --- a/Rally/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,24 +0,0 @@ -<!-- - ~ Copyright 2020 The Android Open Source Project - ~ - ~ Licensed under the Apache License, Version 2.0 (the "License"); - ~ you may not use this file except in compliance with the License. - ~ You may obtain a copy of the License at - ~ - ~ http://www.apache.org/licenses/LICENSE-2.0 - ~ - ~ Unless required by applicable law or agreed to in writing, software - ~ distributed under the License is distributed on an "AS IS" BASIS, - ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - ~ See the License for the specific language governing permissions and - ~ limitations under the License. - ~ - --> -<resources xmlns:tools="http://schemas.android.com/tools"> - <!-- Base application theme. --> - <style name="Theme.Rally" parent="Theme.MaterialComponents.NoActionBar"> - <item name="android:statusBarColor">@color/statusBarColor</item> - <item name="android:windowLightStatusBar" tools:targetApi="m">true</item> - <item name="android:windowBackground">?attr/colorSurface</item> - </style> -</resources> diff --git a/Rally/build.gradle b/Rally/build.gradle deleted file mode 100644 index 19f440f706..0000000000 --- a/Rally/build.gradle +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.example.compose.rally.buildsrc.Libs -import com.example.compose.rally.buildsrc.Urls -import com.example.compose.rally.buildsrc.Versions - -buildscript { - repositories { - google() - jcenter() - } - - dependencies { - classpath Libs.androidGradlePlugin - classpath Libs.Kotlin.gradlePlugin - } -} - -plugins { - id 'com.diffplug.spotless' version '5.7.0' -} - -subprojects { - repositories { - google() - mavenCentral() - jcenter() - - if (!Libs.AndroidX.Compose.snapshot.isEmpty()) { - maven { url Urls.composeSnapshotRepo } - } - } - - apply plugin: 'com.diffplug.spotless' - spotless { - kotlin { - target '**/*.kt' - targetExclude("$buildDir/**/*.kt") - targetExclude('bin/**/*.kt') - - ktlint(Versions.ktlint) - licenseHeaderFile rootProject.file('spotless/copyright.kt') - } - } - - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { - kotlinOptions { - // Treat all Kotlin warnings as errors - allWarningsAsErrors = true - - freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' - - // Enable experimental coroutines APIs, including Flow - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' - freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' - freeCompilerArgs += '-Xopt-in=kotlin.Experimental' - freeCompilerArgs += '-Xallow-jvm-ir-dependencies' - - // Set JVM target to 1.8 - jvmTarget = "1.8" - } - } -} diff --git a/Rally/buildSrc/build.gradle.kts b/Rally/buildSrc/build.gradle.kts deleted file mode 100644 index fc374f6ea3..0000000000 --- a/Rally/buildSrc/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -repositories { - jcenter() -} - -plugins { - `kotlin-dsl` -} diff --git a/Rally/buildSrc/src/main/java/com/example/compose/rally/buildsrc/dependencies.kt b/Rally/buildSrc/src/main/java/com/example/compose/rally/buildsrc/dependencies.kt deleted file mode 100644 index 90640065cc..0000000000 --- a/Rally/buildSrc/src/main/java/com/example/compose/rally/buildsrc/dependencies.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.rally.buildsrc - -object Versions { - const val ktlint = "0.40.0" -} - -object Libs { - const val androidGradlePlugin = "com.android.tools.build:gradle:7.0.0-alpha04" - const val jdkDesugar = "com.android.tools:desugar_jdk_libs:1.0.9" - - const val junit = "junit:junit:4.13" - - const val material = "com.google.android.material:material:1.1.0" - - object Kotlin { - private const val version = "1.4.21" - const val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$version" - const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" - const val extensions = "org.jetbrains.kotlin:kotlin-android-extensions:$version" - } - - object Coroutines { - private const val version = "1.4.1" - const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version" - const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version" - const val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version" - } - - object AndroidX { - const val appcompat = "androidx.appcompat:appcompat:1.2.0-rc01" - const val coreKtx = "androidx.core:core-ktx:1.5.0-alpha02" - - object Compose { - const val snapshot = "" - const val version = "1.0.0-alpha10" - - const val core = "androidx.compose.ui:ui:$version" - const val foundation = "androidx.compose.foundation:foundation:$version" - const val layout = "androidx.compose.foundation:foundation-layout:$version" - const val material = "androidx.compose.material:material:$version" - const val materialIconsExtended = "androidx.compose.material:material-icons-extended:$version" - const val runtime = "androidx.compose.runtime:runtime:$version" - const val runtimeLivedata = "androidx.compose.runtime:runtime-livedata:$version" - const val tooling = "androidx.compose.ui:ui-tooling:$version" - const val test = "androidx.compose.ui:ui-test:$version" - const val uiTest = "androidx.compose.ui:ui-test-junit4:$version" - } - - object Navigation { - private const val version = "2.3.0" - const val fragment = "androidx.navigation:navigation-fragment-ktx:$version" - const val uiKtx = "androidx.navigation:navigation-ui-ktx:$version" - } - - object Test { - private const val version = "1.2.0" - const val core = "androidx.test:core:$version" - const val rules = "androidx.test:rules:$version" - - object Ext { - private const val version = "1.1.2-rc01" - const val junit = "androidx.test.ext:junit-ktx:$version" - } - - const val espressoCore = "androidx.test.espresso:espresso-core:3.2.0" - } - - object Lifecycle { - private const val version = "2.2.0" - const val extensions = "androidx.lifecycle:lifecycle-extensions:$version" - const val livedata = "androidx.lifecycle:lifecycle-livedata-ktx:$version" - const val viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" - } - } -} - -object Urls { - const val composeSnapshotRepo = "https://androidx-dev-prod.appspot.com/snapshots/builds/" + - "${Libs.AndroidX.Compose.snapshot}/artifacts/repository/" -} diff --git a/Rally/debug.keystore b/Rally/debug.keystore deleted file mode 100644 index 6024334a44..0000000000 Binary files a/Rally/debug.keystore and /dev/null differ diff --git a/Rally/gradle.properties b/Rally/gradle.properties deleted file mode 100644 index b2d834ce9c..0000000000 --- a/Rally/gradle.properties +++ /dev/null @@ -1,42 +0,0 @@ -# -# Copyright 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m - -# Turn on parallel compilation, caching and on-demand configuration -org.gradle.configureondemand=true -org.gradle.caching=true -org.gradle.parallel=true - -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true - -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official - -# Enable R8 full mode. -android.enableR8.fullMode=true diff --git a/Rally/gradle/wrapper/gradle-wrapper.jar b/Rally/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index f6b961fd5a..0000000000 Binary files a/Rally/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/Rally/gradle/wrapper/gradle-wrapper.properties b/Rally/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index c7948985bf..0000000000 --- a/Rally/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,22 +0,0 @@ -# -# Copyright 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -#Mon Apr 13 12:05:46 CEST 2020 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https://services.gradle.org/distributions/gradle-6.8-rc-1-bin.zip diff --git a/Rally/gradlew b/Rally/gradlew deleted file mode 100755 index cccdd3d517..0000000000 --- a/Rally/gradlew +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env sh - -############################################################################## -## -## 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="" - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; 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=$((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" - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - -exec "$JAVACMD" "$@" diff --git a/Rally/gradlew.bat b/Rally/gradlew.bat deleted file mode 100644 index e95643d6a2..0000000000 --- a/Rally/gradlew.bat +++ /dev/null @@ -1,84 +0,0 @@ -@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 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= - -@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 init - -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 init - -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 - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -: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 %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/Rally/screenshots/donut.gif b/Rally/screenshots/donut.gif deleted file mode 100644 index 81705ede72..0000000000 Binary files a/Rally/screenshots/donut.gif and /dev/null differ diff --git a/Rally/screenshots/rally.gif b/Rally/screenshots/rally.gif deleted file mode 100644 index db55cad765..0000000000 Binary files a/Rally/screenshots/rally.gif and /dev/null differ diff --git a/Rally/settings.gradle b/Rally/settings.gradle deleted file mode 100644 index 94b0ac41e9..0000000000 --- a/Rally/settings.gradle +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -include ':app' -rootProject.name = "Rally" \ No newline at end of file diff --git a/Rally/spotless/copyright.kt b/Rally/spotless/copyright.kt deleted file mode 100644 index 806db0fb54..0000000000 --- a/Rally/spotless/copyright.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright $YEAR The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - diff --git a/Reply/.gitignore b/Reply/.gitignore new file mode 100644 index 0000000000..834ecd9dff --- /dev/null +++ b/Reply/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +.kotlin/ diff --git a/Rally/ASSETS_LICENSE b/Reply/ASSETS_LICENSE similarity index 100% rename from Rally/ASSETS_LICENSE rename to Reply/ASSETS_LICENSE diff --git a/Reply/README.md b/Reply/README.md new file mode 100644 index 0000000000..b274582e80 --- /dev/null +++ b/Reply/README.md @@ -0,0 +1,106 @@ +# Reply sample + +This sample is a [Jetpack Compose][compose] implementation of [Reply][reply], a material design study for adaptive design. + +To try out this sample app, use the latest stable version +of [Android Studio](https://developer.android.com/studio). +[Resizeable Emulator](https://developer.android.com/about/versions/12/12L/get#resizable-emulator) +You can clone this repository or import the +project from Android Studio following the steps +[here](https://developer.android.com/jetpack/compose/setup#sample). + +This sample showcases: + +* Adaptive apps for mobile, tablets and foldables +* Material navigation components +* [Material 3 theming][materialtheming] & dynamic colors. + +## Design & Screenshots + +<img src="screenshots/reply.gif"/> + +<img src="screenshots/medium_and_large_display.png"> + +## 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. + +<img src="screenshots/dynamic_size.gif"/> + + +#### [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. + +<img src="screenshots/fold_unfold.png"> + + +#### [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. + + +<img src="screenshots/compact_medium_large_displays.png"> + + + + +#### [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) + + +<img src="screenshots/dynamic_theming.png"> + + + + +#### [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. + + +<img src="screenshots/medium_and_large_display.png"> + + + + +#### [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..c6273685ce --- /dev/null +++ b/Reply/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + + <application + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.Reply" + android:enableOnBackInvokedCallback="true" + tools:targetApi="31"> + <activity + android:name=".ui.MainActivity" + android:exported="true" + android:label="@string/app_name" + android:theme="@style/Theme.Reply" + android:windowSoftInputMode="adjustResize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> 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<Account> + fun getAllUserAccounts(): Flow<List<Account>> + fun getContactAccountByUid(uid: Long): Flow<Account> +} 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<Account> = flow { + emit(LocalAccountsDataProvider.getDefaultUserAccount()) + } + + override fun getAllUserAccounts(): Flow<List<Account>> = flow { + emit(LocalAccountsDataProvider.allUserAccounts) + } + + override fun getContactAccountByUid(uid: Long): Flow<Account> = 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<Account> = emptyList(), + val subject: String, + val body: String, + val attachments: List<EmailAttachment> = emptyList(), + var isImportant: Boolean = false, + var isStarred: Boolean = false, + var mailbox: MailboxType = MailboxType.INBOX, + val createdAt: String, + val threads: List<Email> = 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<List<Email>> + fun getCategoryEmails(category: MailboxType): Flow<List<Email>> + fun getAllFolders(): List<String> + fun getEmailFromId(id: Long): Flow<Email?> +} 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<List<Email>> = flow { + emit(LocalEmailsDataProvider.allEmails) + } + + override fun getCategoryEmails(category: MailboxType): Flow<List<Email>> = flow { + val categoryEmails = LocalEmailsDataProvider.allEmails.filter { it.mailbox == category } + emit(categoryEmails) + } + + override fun getAllFolders(): List<String> { + return LocalEmailsDataProvider.getAllFolders() + } + + override fun getEmailFromId(id: Long): Flow<Email?> = 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<DisplayFeature>, + 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<FoldingFeature>().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<DisplayFeature>, + 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<Route.Inbox> { + ReplyInboxScreen( + contentType = contentType, + replyHomeUIState = replyHomeUIState, + navigationType = navigationType, + displayFeatures = displayFeatures, + closeDetailScreen = closeDetailScreen, + navigateToDetail = navigateToDetail, + toggleSelectedEmail = toggleSelectedEmail + ) + } + composable<Route.DirectMessages> { + EmptyComingSoon() + } + composable<Route.Articles> { + EmptyComingSoon() + } + composable<Route.Groups> { + 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<ReplyHomeUIState> = _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<Email> = emptyList(), + val selectedEmails: Set<Long> = 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<DisplayFeature>, + 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<Email>, + openedEmail: Email?, + selectedEmailIds: Set<Long>, + 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<Email>, + onSearchItemSelected: (Email) -> Unit, + modifier: Modifier = Modifier +) { + var query by remember { mutableStateOf("") } + var expanded by remember { mutableStateOf(false) } + val searchResults = remember { mutableStateListOf<Email>() } + 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 @@ +<!-- + 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> + <aapt:attr name="android:fillColor"> + <gradient + android:endX="85.84757" + android:endY="92.4963" + android:startX="42.9492" + android:startY="49.59793" + android:type="linear"> + <item + android:color="#44000000" + android:offset="0.0" /> + <item + android:color="#00000000" + android:offset="1.0" /> + </gradient> + </aapt:attr> + </path> + <path + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" + android:strokeWidth="1" + android:strokeColor="#00000000" /> +</vector> \ 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <group android:scaleX="0" + android:scaleY="0" + android:translateX="54" + android:translateY="54"> + <path android:fillColor="#3DDC84" + android:pathData="M0,0h108v108h-108z"/> + <path android:fillColor="#00000000" android:pathData="M9,0L9,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,0L19,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M29,0L29,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M39,0L39,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M49,0L49,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M59,0L59,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M69,0L69,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M79,0L79,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M89,0L89,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M99,0L99,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,9L108,9" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,19L108,19" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,29L108,29" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,39L108,39" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,49L108,49" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,59L108,59" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,69L108,69" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,79L108,79" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,89L108,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,99L108,99" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,29L89,29" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,39L89,39" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,49L89,49" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,59L89,59" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,69L89,69" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,79L89,79" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M29,19L29,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M39,19L39,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M49,19L49,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M59,19L59,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M69,19L69,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M79,19L79,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + </group> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background"/> + <foreground android:drawable="@mipmap/ic_launcher_foreground"/> +</adaptive-icon> \ 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background"/> + <foreground android:drawable="@mipmap/ic_launcher_foreground"/> +</adaptive-icon> \ 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 @@ +<!-- + 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. + --> +<resources> + <string name="app_name">Reply</string> + <string name="navigation_drawer">Navigation Drawer</string> + <string name="close_drawer">Close drawer</string> + <string name="tab_inbox">Inbox</string> + <string name="tab_article">Articles</string> + <string name="tab_dm">Direct Messages</string> + <string name="tab_groups">Groups</string> + + <string name="profile">Profile</string> + <string name="search">Search</string> + <string name="reply">Reply</string> + <string name="reply_all">Reply All</string> + + <string name="edit">Edit</string> + <string name="compose">Compose</string> + + <string name="empty_screen_title">Screen under construction</string> + <string name="empty_screen_subtitle">This screen is still under construction. This sample will help you learn about adaptive layouts in Jetpack Compose</string> + + <string name="back_button">Back</string> + <string name="more_options_button">More options</string> + <string name="messages">Messages</string> + <string name="four_hours_ago">4 hrs ago</string> + + <string name="search_emails">Search emails</string> + <string name="no_item_found">No item found</string> + <string name="no_search_history">No search history</string> +</resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + 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. + --> +<resources> + + <style name="Theme.Reply" parent="android:Theme.Material.Light.NoActionBar" /> +</resources> \ No newline at end of file diff --git a/Reply/build.gradle.kts b/Reply/build.gradle.kts new file mode 100644 index 0000000000..2b3f56e8a2 --- /dev/null +++ b/Reply/build.gradle.kts @@ -0,0 +1,73 @@ +/* + * 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.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 + alias(libs.plugins.spotless) apply false +} + +apply("${project.rootDir}/buildscripts/toml-updater-config.gradle") + +subprojects { + apply(plugin = "com.diffplug.spotless") + configure<com.diffplug.gradle.spotless.SpotlessExtension> { + ratchetFrom = "origin/main" + kotlin { + target("**/*.kt") + targetExclude("**/build/**/*.kt") + ktlint().editorConfigOverride( + mapOf( + "ktlint_code_style" to "android_studio", + "ij_kotlin_allow_trailing_comma" to true, + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + // 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"), "(^(?![\\/ ]\\*).*$)") + } + kotlinGradle { + target("*.gradle.kts") + ktlint() + } + } +} diff --git a/Reply/buildscripts/toml-updater-config.gradle b/Reply/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/Reply/buildscripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/Reply/debug_2.keystore b/Reply/debug_2.keystore new file mode 100644 index 0000000000..b42c971788 Binary files /dev/null and b/Reply/debug_2.keystore differ diff --git a/Reply/gradle.properties b/Reply/gradle.properties new file mode 100644 index 0000000000..cd9d7e39fc --- /dev/null +++ b/Reply/gradle.properties @@ -0,0 +1,42 @@ +# Copyright 2022 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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 -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# 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 +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +#android.nonTransitiveRClass=true + +# Turn on parallel compilation, caching and on-demand configuration +org.gradle.configureondemand=true +org.gradle.caching=true +org.gradle.parallel=true diff --git a/Reply/gradle/libs.versions.toml b/Reply/gradle/libs.versions.toml new file mode 100644 index 0000000000..29943df2e6 --- /dev/null +++ b/Reply/gradle/libs.versions.toml @@ -0,0 +1,177 @@ +##### +# This file is duplicated to individual samples from the global scripts/libs.versions.toml +# Do not add a dependency to an individual sample, edit the global version instead. +##### +[versions] +accompanist = "0.37.2" +android-material3 = "1.13.0-alpha13" +androidGradlePlugin = "8.9.2" +androidx-activity-compose = "1.10.1" +androidx-appcompat = "1.7.0" +androidx-compose-bom = "2025.04.01" +androidx-constraintlayout = "1.1.0" +androidx-core-splashscreen = "1.0.1" +androidx-corektx = "1.16.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.9" +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.2" +google-maps = "18.2.0" +gradle-versions = "0.52.0" +hilt = "2.56.2" +hiltExt = "1.2.0" +horologist = "0.6.23" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.1.20" +kotlinx-serialization-json = "1.7.3" +kotlinx_immutable = "0.3.8" +ksp = "2.1.20-2.0.0" +maps-compose = "3.1.1" +# @keep +minSdk = "21" +okhttp = "4.12.0" +play-services-wearable = "18.1.0" +robolectric = "4.14.1" +roborazzi = "1.43.1" +rome = "2.1.0" +room = "2.7.1" +secrets = "2.0.1" +spotless = "7.0.3" +# @keep +targetSdk = "33" +version-catalog-update = "1.0.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = "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" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/Owl/gradle/wrapper/gradle-wrapper.jar b/Reply/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from Owl/gradle/wrapper/gradle-wrapper.jar rename to Reply/gradle/wrapper/gradle-wrapper.jar diff --git a/Reply/gradle/wrapper/gradle-wrapper.properties b/Reply/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..d6c8bc7bf8 --- /dev/null +++ b/Reply/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.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Owl/gradlew b/Reply/gradlew similarity index 100% rename from Owl/gradlew rename to Reply/gradlew diff --git a/Owl/gradlew.bat b/Reply/gradlew.bat similarity index 100% rename from Owl/gradlew.bat rename to Reply/gradlew.bat diff --git a/Reply/screenshots/compact_medium_large_displays.png b/Reply/screenshots/compact_medium_large_displays.png new file mode 100644 index 0000000000..8b94a2cb36 Binary files /dev/null and b/Reply/screenshots/compact_medium_large_displays.png differ diff --git a/Reply/screenshots/dynamic_size.gif b/Reply/screenshots/dynamic_size.gif new file mode 100644 index 0000000000..ccbe31b88b Binary files /dev/null and b/Reply/screenshots/dynamic_size.gif differ diff --git a/Reply/screenshots/dynamic_theming.png b/Reply/screenshots/dynamic_theming.png new file mode 100644 index 0000000000..4be645914f Binary files /dev/null and b/Reply/screenshots/dynamic_theming.png differ diff --git a/Reply/screenshots/fold_unfold.png b/Reply/screenshots/fold_unfold.png new file mode 100644 index 0000000000..4deac4da5d Binary files /dev/null and b/Reply/screenshots/fold_unfold.png differ diff --git a/Reply/screenshots/medium_and_large_display.png b/Reply/screenshots/medium_and_large_display.png new file mode 100644 index 0000000000..210267366b Binary files /dev/null and b/Reply/screenshots/medium_and_large_display.png differ diff --git a/Reply/screenshots/reply.gif b/Reply/screenshots/reply.gif new file mode 100644 index 0000000000..3f0467d2f7 Binary files /dev/null and b/Reply/screenshots/reply.gif differ diff --git a/Reply/screenshots/reply_large_screen.png b/Reply/screenshots/reply_large_screen.png new file mode 100644 index 0000000000..bf653ae38d Binary files /dev/null and b/Reply/screenshots/reply_large_screen.png differ diff --git a/Reply/screenshots/reply_logo.png b/Reply/screenshots/reply_logo.png new file mode 100644 index 0000000000..26e188d25f Binary files /dev/null and b/Reply/screenshots/reply_logo.png differ diff --git a/Reply/screenshots/reply_medium_screen.png b/Reply/screenshots/reply_medium_screen.png new file mode 100644 index 0000000000..0b4a6fe0af Binary files /dev/null and b/Reply/screenshots/reply_medium_screen.png differ diff --git a/Reply/screenshots/reply_phone.png b/Reply/screenshots/reply_phone.png new file mode 100644 index 0000000000..c972251221 Binary files /dev/null and b/Reply/screenshots/reply_phone.png differ diff --git a/Reply/screenshots/reply_theme.png b/Reply/screenshots/reply_theme.png new file mode 100644 index 0000000000..189e8114fb Binary files /dev/null and b/Reply/screenshots/reply_theme.png differ diff --git a/Reply/settings.gradle.kts b/Reply/settings.gradle.kts new file mode 100644 index 0000000000..5655826457 --- /dev/null +++ b/Reply/settings.gradle.kts @@ -0,0 +1,38 @@ +/* + * 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 = "Reply" +include(":app") diff --git a/Jetsurvey/spotless/copyright.kt b/Reply/spotless/copyright.kt similarity index 100% rename from Jetsurvey/spotless/copyright.kt rename to Reply/spotless/copyright.kt diff --git a/readme/crane.png b/readme/crane.png deleted file mode 100644 index 15b91610e4..0000000000 Binary files a/readme/crane.png and /dev/null differ diff --git a/readme/jetcaster-hero.png b/readme/jetcaster-hero.png new file mode 100644 index 0000000000..37fbbfa1ba Binary files /dev/null and b/readme/jetcaster-hero.png differ diff --git a/readme/jetlagged_heading.png b/readme/jetlagged_heading.png new file mode 100644 index 0000000000..6a52cca8d2 Binary files /dev/null and b/readme/jetlagged_heading.png differ diff --git a/readme/jetsurvey.png b/readme/jetsurvey.png deleted file mode 100644 index 1f5fb1c435..0000000000 Binary files a/readme/jetsurvey.png and /dev/null differ diff --git a/readme/material_catalog.png b/readme/material_catalog.png new file mode 100644 index 0000000000..fa7f9e6ab6 Binary files /dev/null and b/readme/material_catalog.png differ diff --git a/readme/nia.png b/readme/nia.png new file mode 100644 index 0000000000..b43aea5436 Binary files /dev/null and b/readme/nia.png differ diff --git a/readme/owl.png b/readme/owl.png deleted file mode 100644 index 4eaf06a252..0000000000 Binary files a/readme/owl.png and /dev/null differ diff --git a/readme/rally.png b/readme/rally.png deleted file mode 100644 index 2ded2a1c6b..0000000000 Binary files a/readme/rally.png and /dev/null differ diff --git a/readme/reply.png b/readme/reply.png new file mode 100644 index 0000000000..dfd12904e5 Binary files /dev/null and b/readme/reply.png differ diff --git a/readme/samples_montage.gif b/readme/samples_montage.gif index 5e768c8443..42911e4cfe 100644 Binary files a/readme/samples_montage.gif and b/readme/samples_montage.gif differ diff --git a/readme/screenshots/Crane.png b/readme/screenshots/Crane.png deleted file mode 100644 index d8a2f320f7..0000000000 Binary files a/readme/screenshots/Crane.png and /dev/null differ diff --git a/readme/screenshots/Jetcaster.png b/readme/screenshots/Jetcaster.png index ac0d281975..953351d1ce 100644 Binary files a/readme/screenshots/Jetcaster.png and b/readme/screenshots/Jetcaster.png differ diff --git a/readme/screenshots/Jetchat.png b/readme/screenshots/Jetchat.png index 8dd33d0bcf..f1e7f94c65 100644 Binary files a/readme/screenshots/Jetchat.png and b/readme/screenshots/Jetchat.png differ diff --git a/readme/screenshots/Jetsurvey.png b/readme/screenshots/Jetsurvey.png deleted file mode 100644 index 2ba126a805..0000000000 Binary files a/readme/screenshots/Jetsurvey.png and /dev/null differ diff --git a/readme/screenshots/Material_Catalog.png b/readme/screenshots/Material_Catalog.png new file mode 100644 index 0000000000..8e511d9c6f Binary files /dev/null and b/readme/screenshots/Material_Catalog.png differ diff --git a/readme/screenshots/NiA.png b/readme/screenshots/NiA.png new file mode 100644 index 0000000000..b2ff30dd72 Binary files /dev/null and b/readme/screenshots/NiA.png differ diff --git a/readme/screenshots/Owl.png b/readme/screenshots/Owl.png deleted file mode 100644 index a83b698343..0000000000 Binary files a/readme/screenshots/Owl.png and /dev/null differ diff --git a/readme/screenshots/Rally.png b/readme/screenshots/Rally.png deleted file mode 100644 index 7dde9b8ab8..0000000000 Binary files a/readme/screenshots/Rally.png and /dev/null differ diff --git a/readme/screenshots/Reply.png b/readme/screenshots/Reply.png new file mode 100644 index 0000000000..c972251221 Binary files /dev/null and b/readme/screenshots/Reply.png differ diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000000..1312972232 --- /dev/null +++ b/renovate.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base", ":dependencyDashboard" + ], + "dependencyDashboardApproval": true, + "packageRules": [ + { + "groupName": "Androidx Lifecycle deps", + "matchPackagePatterns": ["androidx.lifecycle", "androidx.navigation:navigation-compose"] + }, + { + "groupName": "Compose Dependencies", + "matchPackagePatterns": "^androidx\\..*compose\\." + }, + { + "groupName": "Kotlin Dependencies", + "matchPackagePrefixes": ["org.jetbrains.kotlin"] + } + ] +} diff --git a/scripts/checksum.sh b/scripts/checksum.sh index 44a8b4ec19..bc8732f5b5 100755 --- a/scripts/checksum.sh +++ b/scripts/checksum.sh @@ -1,5 +1,5 @@ # -# Copyright 2019 Google, Inc. +# Copyright 2022 Google, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -30,11 +30,11 @@ checksum_file() { FILES=() while read -r -d ''; do FILES+=("$REPLY") -done < <(find $SAMPLE -type f \( -name "build.gradle*" -o -name "gradle-wrapper.properties" \) -print0) +done < <(find $SAMPLE -type f \( -name "build.gradle*" -o -name "gradle-wrapper.properties" -o -name "robolectric.properties" \) -print0) # Loop through files and append MD5 to result file for FILE in ${FILES[@]}; do echo $(checksum_file $FILE) >> $RESULT_FILE done -# Now sort the file so that it is +# Now sort the file so that it is idempotent sort $RESULT_FILE -o $RESULT_FILE diff --git a/scripts/duplicate_version_config.sh b/scripts/duplicate_version_config.sh new file mode 100755 index 0000000000..9ab216c798 --- /dev/null +++ b/scripts/duplicate_version_config.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# 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. + +######################################################################## +# +# Duplicates libs.versions.toml into each sample from master copy. +# +# Example: To run build over all projects run: +# ./scripts/duplicate_version_config.sh +# +######################################################################## + +set -xe + +cp scripts/libs.versions.toml Jetcaster/gradle/libs.versions.toml +cp scripts/libs.versions.toml Jetchat/gradle/libs.versions.toml +cp scripts/libs.versions.toml JetLagged/gradle/libs.versions.toml +cp scripts/libs.versions.toml JetNews/gradle/libs.versions.toml +cp scripts/libs.versions.toml Jetsnack/gradle/libs.versions.toml +cp scripts/libs.versions.toml Reply/gradle/libs.versions.toml + +cp scripts/toml-updater-config.gradle Jetcaster/buildscripts/toml-updater-config.gradle +cp scripts/toml-updater-config.gradle Jetchat/buildscripts/toml-updater-config.gradle +cp scripts/toml-updater-config.gradle JetLagged/buildscripts/toml-updater-config.gradle +cp scripts/toml-updater-config.gradle JetNews/buildscripts/toml-updater-config.gradle +cp scripts/toml-updater-config.gradle Jetsnack/buildscripts/toml-updater-config.gradle +cp scripts/toml-updater-config.gradle Reply/buildscripts/toml-updater-config.gradle + +# TODO: Figure out a way of copying the spotless config into each project following +# this PR: https://github.com/android/compose-samples/pull/1549 + +#cp scripts/init.gradle.kts Jetcaster/buildscripts/init.gradle.kts +#cp scripts/init.gradle.kts Jetchat/buildscripts/init.gradle.kts +#cp scripts/init.gradle.kts JetLagged/buildscripts/init.gradle.kts +#cp scripts/init.gradle.kts JetNews/buildscripts/init.gradle.kts +#cp scripts/init.gradle.kts Jetsnack/buildscripts/init.gradle.kts +#cp scripts/init.gradle.kts Reply/buildscripts/init.gradle.kts \ No newline at end of file diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000000..c0c2497191 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# 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. + +######################################################################## +# +# Automatically apply code formatting to samples +# +# Example: ./scripts/format.sh +# +######################################################################## + +set -xe + +./scripts/gradlew_recursive.sh --init-script buildscripts/init.gradle.kts spotlessApply diff --git a/scripts/gradlew_recursive.sh b/scripts/gradlew_recursive.sh index cae539ba61..70474fb3f3 100755 --- a/scripts/gradlew_recursive.sh +++ b/scripts/gradlew_recursive.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Copyright (C) 2020 The Android Open Source Project +# 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. diff --git a/scripts/init.gradle.kts b/scripts/init.gradle.kts new file mode 100644 index 0000000000..1b7a54264c --- /dev/null +++ b/scripts/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<com.diffplug.gradle.spotless.SpotlessPlugin>() + extensions.configure<com.diffplug.gradle.spotless.SpotlessExtension> { + 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/scripts/libs.versions.toml b/scripts/libs.versions.toml new file mode 100644 index 0000000000..29943df2e6 --- /dev/null +++ b/scripts/libs.versions.toml @@ -0,0 +1,177 @@ +##### +# This file is duplicated to individual samples from the global scripts/libs.versions.toml +# Do not add a dependency to an individual sample, edit the global version instead. +##### +[versions] +accompanist = "0.37.2" +android-material3 = "1.13.0-alpha13" +androidGradlePlugin = "8.9.2" +androidx-activity-compose = "1.10.1" +androidx-appcompat = "1.7.0" +androidx-compose-bom = "2025.04.01" +androidx-constraintlayout = "1.1.0" +androidx-core-splashscreen = "1.0.1" +androidx-corektx = "1.16.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.9" +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.2" +google-maps = "18.2.0" +gradle-versions = "0.52.0" +hilt = "2.56.2" +hiltExt = "1.2.0" +horologist = "0.6.23" +jdkDesugar = "2.1.5" +junit = "4.13.2" +kotlin = "2.1.20" +kotlinx-serialization-json = "1.7.3" +kotlinx_immutable = "0.3.8" +ksp = "2.1.20-2.0.0" +maps-compose = "3.1.1" +# @keep +minSdk = "21" +okhttp = "4.12.0" +play-services-wearable = "18.1.0" +robolectric = "4.14.1" +roborazzi = "1.43.1" +rome = "2.1.0" +room = "2.7.1" +secrets = "2.0.1" +spotless = "7.0.3" +# @keep +targetSdk = "33" +version-catalog-update = "1.0.0" + +[libraries] +accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +android-material3 = { module = "com.google.android.material:material", version.ref = "android-material3" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-compose-animation = { module = "androidx.compose.animation:animation" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } +androidx-compose-materialWindow = { module = "androidx.compose.material3:material3-window-size-class" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } +androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-corektx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-glance = { module = "androidx.glance:glance", version.ref = "androidx-glance" } +androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" } +androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-ext-truth" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } +androidx-test-runner = "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" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/scripts/test_snapshot.sh b/scripts/test_snapshot.sh new file mode 100755 index 0000000000..f0694d9dbd --- /dev/null +++ b/scripts/test_snapshot.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# 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. + +######################################################################## +# +# Allows testing a snapshot version across all samples +# +# Example: To run build over all projects run: +# ./scripts/test_snapshot.sh +# +######################################################################## +set -xe + +if [ -z "$1" ]; then + read -p "Enter compose version e.g. 1.3.0: " compose_ver +else + echo "Using compose version: $1" + compose_ver=$1 +fi +if [ -z "$2" ]; then + read -p "Enter snapshot ID: " snapshot +else + echo "Using compose snapshot: $2" + snapshot=$2 +fi +export COMPOSE_SNAPSHOT_ID=$snapshot + +# Switch version to SNAPSHOT +cp ./scripts/libs.versions.toml ./scripts/libs.versions.toml.tmp +if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' -e 's/^compose = ".*"/compose = "'$compose_ver'-SNAPSHOT"/g' ./scripts/libs.versions.toml +else + sed -i -e 's/^compose = ".*"/compose = "'$compose_ver'-SNAPSHOT"/g' ./scripts/libs.versions.toml +fi + +# Copy to all samples and verify +./scripts/duplicate_version_config.sh +./scripts/verify_samples.sh +./scripts/gradlew_recursive.sh testDebug --stacktrace + +# Undo all changes +mv ./scripts/libs.versions.toml.tmp ./scripts/libs.versions.toml +./scripts/duplicate_version_config.sh diff --git a/scripts/toml-updater-config.gradle b/scripts/toml-updater-config.gradle new file mode 100644 index 0000000000..801c23d3e2 --- /dev/null +++ b/scripts/toml-updater-config.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +versionCatalogUpdate { + sortByKey.set(true) + + keep { + // keep versions without any library or plugin reference + keepUnusedVersions.set(true) + } +} + +def isNonStable = { String version -> + def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) } + def regex = /^[0-9,.v-]+(-r)?$/ + return !stableKeyword && !(version ==~ regex) +} + +tasks.named("dependencyUpdates").configure { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) { + reject('Release candidate') + } + } + } + } +} \ No newline at end of file diff --git a/scripts/updateDeps.sh b/scripts/updateDeps.sh new file mode 100755 index 0000000000..d0fc36d772 --- /dev/null +++ b/scripts/updateDeps.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# 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. + +######################################################################## +# +# Updates dependencies using Reply as the source of truth (then copies Reply's +# output into each sample) +# +# Example: To run build over all projects run: +# ./scripts/updateDeps.sh +# +######################################################################## + +set -xe + +./Jetcaster/gradlew -p ./Jetcaster versionCatalogUpdate + +cp Jetcaster/gradle/libs.versions.toml scripts/libs.versions.toml +./scripts/duplicate_version_config.sh diff --git a/scripts/verify_samples.sh b/scripts/verify_samples.sh new file mode 100755 index 0000000000..1f9833b737 --- /dev/null +++ b/scripts/verify_samples.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# 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. + +######################################################################## +# +# Verifies samples build, lint error free and are formatted correctly. +# +# Example: ./scripts/verify_samples.sh +# +######################################################################## + +set -xe + +./scripts/gradlew_recursive.sh assembleDebug +./scripts/gradlew_recursive.sh lintDebug +if ! ./scripts/gradlew_recursive.sh spotlessCheck ; then + echo "Formatting error. Try running scripts/format.sh" +fi