diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 7fad64b118..09e679b5f4 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -16,7 +16,9 @@ on: required: true type: string secrets: - ACCESS_TOKEN: + GH_APP_ID: + required: true + GH_APP_PRIVATE_KEY: required: true ANDROID_RELEASE_KEYSTORE_B64: required: true @@ -30,12 +32,21 @@ jobs: name: ${{ inputs.app-type-lower }}-build runs-on: ubuntu-latest steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + - name: Checkout repository uses: actions/checkout@v4 with: submodules: 'recursive' fetch-depth: 1 - token: ${{ secrets.ACCESS_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Set up JDK 17 uses: actions/setup-java@v4 diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml new file mode 100644 index 0000000000..b909b3b32e --- /dev/null +++ b/.github/workflows/create-tag.yml @@ -0,0 +1,99 @@ +name: Create Tag + +on: + push: + branches: + - 'release/student' + - 'release/teacher' + - 'release/parent' + +jobs: + create-tag: + name: create-tag + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + env: + JIRA_USERNAME: ${{ secrets.JIRA_USERNAME }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Determine app from branch + id: app-info + run: | + APP_NAME=$(echo "${GITHUB_REF_NAME}" | sed 's|release/||') + echo "app-name=${APP_NAME}" >> "$GITHUB_OUTPUT" + + VERSION_CODE=$(grep 'versionCode' apps/${APP_NAME}/build.gradle | head -1 | awk '{print $NF}') + VERSION_NAME=$(grep 'versionName' apps/${APP_NAME}/build.gradle | head -1 | awk '{print $NF}') + VERSION_NAME=${VERSION_NAME//\"/} + VERSION_NAME=${VERSION_NAME//\'/} + + TAG="${APP_NAME}-${VERSION_NAME}-${VERSION_CODE}" + echo "version-code=${VERSION_CODE}" >> "$GITHUB_OUTPUT" + echo "version-name=${VERSION_NAME}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + echo "App: ${APP_NAME}" + echo "Version: ${VERSION_NAME} (${VERSION_CODE})" + echo "Tag: ${TAG}" + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + id: create-tag + run: | + TAG="${{ steps.app-info.outputs.tag }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping tag creation" + echo "tag-exists=true" >> "$GITHUB_OUTPUT" + else + git tag "$TAG" + git push origin "$TAG" + echo "tag-exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + if: steps.create-tag.outputs.tag-exists != 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + APP_NAME="${{ steps.app-info.outputs.app-name }}" + VERSION_NAME="${{ steps.app-info.outputs.version-name }}" + VERSION_CODE="${{ steps.app-info.outputs.version-code }}" + TAG="${{ steps.app-info.outputs.tag }}" + + gh release create "$TAG" \ + --title "${APP_NAME} ${VERSION_NAME} (${VERSION_CODE})" \ + --notes "${APP_NAME} ${VERSION_NAME} (${VERSION_CODE})" \ + --draft + + - name: Update JIRA fix versions + if: steps.create-tag.outputs.tag-exists != 'true' && env.JIRA_USERNAME != '' + run: | + cd scripts + npm install jira-client@4.0.1 + node update-jira-issues.js "${{ steps.app-info.outputs.tag }}" \ No newline at end of file diff --git a/.github/workflows/deploy-parent.yml b/.github/workflows/deploy-parent.yml new file mode 100644 index 0000000000..534374088c --- /dev/null +++ b/.github/workflows/deploy-parent.yml @@ -0,0 +1,79 @@ +name: Deploy Parent + +on: + push: + tags: + - 'parent-*' + +concurrency: + group: deploy-parent + cancel-in-progress: false + +jobs: + build-and-deploy: + name: build-and-deploy + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + java-version: '17' + + - name: Decode release keystore + env: + KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} + run: | + echo "$KEYSTORE_B64" | base64 --decode > release.jks + chmod 600 release.jks + + - name: Build production release + run: | + ./gradle/gradlew -p apps \ + :parent:assembleProdRelease \ + :parent:bundleProdRelease \ + --stacktrace \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process \ + -Pandroid.injected.signing.store.file=$(pwd)/release.jks \ + -Pandroid.injected.signing.store.password="${KEYSTORE_PASSWORD}" \ + -Pandroid.injected.signing.key.alias="${KEYSTORE_KEY_ALIAS}" \ + -Pandroid.injected.signing.key.password="${KEYSTORE_KEY_PASSWORD}" + env: + GRADLE_OPTS: "-Djava.net.preferIPv4Stack=true" + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD }} + + - name: Upload to Google Play + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GPLAY_SERVICE_ACCOUNT_KEY }} + packageName: com.instructure.parentapp + releaseFiles: apps/parent/build/outputs/bundle/prodRelease/parent-prod-release.aab + track: internal + status: completed + + - name: Cleanup sensitive files + if: always() + run: rm -f release.jks \ No newline at end of file diff --git a/.github/workflows/deploy-student.yml b/.github/workflows/deploy-student.yml new file mode 100644 index 0000000000..44d4fde07a --- /dev/null +++ b/.github/workflows/deploy-student.yml @@ -0,0 +1,86 @@ +name: Deploy Student + +on: + push: + tags: + - 'student-*' + +concurrency: + group: deploy-student + cancel-in-progress: false + +jobs: + build-and-deploy: + name: build-and-deploy + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + java-version: '17' + + - name: Decode release keystore + env: + KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} + run: | + echo "$KEYSTORE_B64" | base64 --decode > release.jks + chmod 600 release.jks + + - name: Build production release + run: | + ./gradle/gradlew -p apps \ + :student:assembleProdRelease \ + :student:bundleProdRelease \ + --stacktrace \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process \ + -Pandroid.injected.signing.store.file=$(pwd)/release.jks \ + -Pandroid.injected.signing.store.password="${KEYSTORE_PASSWORD}" \ + -Pandroid.injected.signing.key.alias="${KEYSTORE_KEY_ALIAS}" \ + -Pandroid.injected.signing.key.password="${KEYSTORE_KEY_PASSWORD}" + env: + GRADLE_OPTS: "-Djava.net.preferIPv4Stack=true" + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD }} + + - name: Extract update priority + id: priority + run: | + UPDATE_PRIORITY=$(grep 'updatePriority' apps/student/build.gradle | head -1 | awk '{print $NF}') + echo "value=${UPDATE_PRIORITY}" >> "$GITHUB_OUTPUT" + + - name: Upload to Google Play + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GPLAY_SERVICE_ACCOUNT_KEY }} + packageName: com.instructure.candroid + releaseFiles: apps/student/build/outputs/bundle/prodRelease/student-prod-release.aab + track: internal + inAppUpdatePriority: ${{ steps.priority.outputs.value }} + status: completed + + - name: Cleanup sensitive files + if: always() + run: rm -f release.jks \ No newline at end of file diff --git a/.github/workflows/deploy-teacher.yml b/.github/workflows/deploy-teacher.yml new file mode 100644 index 0000000000..22b57be63c --- /dev/null +++ b/.github/workflows/deploy-teacher.yml @@ -0,0 +1,86 @@ +name: Deploy Teacher + +on: + push: + tags: + - 'teacher-*' + +concurrency: + group: deploy-teacher + cancel-in-progress: false + +jobs: + build-and-deploy: + name: build-and-deploy + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + java-version: '17' + + - name: Decode release keystore + env: + KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} + run: | + echo "$KEYSTORE_B64" | base64 --decode > release.jks + chmod 600 release.jks + + - name: Build production release + run: | + ./gradle/gradlew -p apps \ + :teacher:assembleProdRelease \ + :teacher:bundleProdRelease \ + --stacktrace \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process \ + -Pandroid.injected.signing.store.file=$(pwd)/release.jks \ + -Pandroid.injected.signing.store.password="${KEYSTORE_PASSWORD}" \ + -Pandroid.injected.signing.key.alias="${KEYSTORE_KEY_ALIAS}" \ + -Pandroid.injected.signing.key.password="${KEYSTORE_KEY_PASSWORD}" + env: + GRADLE_OPTS: "-Djava.net.preferIPv4Stack=true" + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD }} + + - name: Extract update priority + id: priority + run: | + UPDATE_PRIORITY=$(grep 'updatePriority' apps/teacher/build.gradle | head -1 | awk '{print $NF}') + echo "value=${UPDATE_PRIORITY}" >> "$GITHUB_OUTPUT" + + - name: Upload to Google Play + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GPLAY_SERVICE_ACCOUNT_KEY }} + packageName: com.instructure.teacher + releaseFiles: apps/teacher/build/outputs/bundle/prodRelease/teacher-prod-release.aab + track: internal + inAppUpdatePriority: ${{ steps.priority.outputs.value }} + status: completed + + - name: Cleanup sensitive files + if: always() + run: rm -f release.jks \ No newline at end of file diff --git a/.github/workflows/import-translations.yml b/.github/workflows/import-translations.yml new file mode 100644 index 0000000000..d0dbc99543 --- /dev/null +++ b/.github/workflows/import-translations.yml @@ -0,0 +1,46 @@ +name: Import Translations + +on: + workflow_dispatch: + +jobs: + import-translations: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Configure git + run: | + git config user.name "inst-danger" + git config user.email "ios@instructure.com" + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + + - name: Import translations from S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.TRANSLATIONS_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.TRANSLATIONS_AWS_SECRET_ACCESS_KEY }} + run: ruby translations/import.rb + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH=$(git branch --show-current) + gh pr create --base master --title "Update translations" --body "Automated translation import" --head "$BRANCH" \ No newline at end of file diff --git a/.github/workflows/pr-pipeline.yml b/.github/workflows/pr-pipeline.yml index 2b0a1c9dce..1daedd2349 100644 --- a/.github/workflows/pr-pipeline.yml +++ b/.github/workflows/pr-pipeline.yml @@ -25,7 +25,8 @@ jobs: app-type-lower: parent firebase-app-id-secret: FIREBASE_ANDROID_PARENT_APP_ID secrets: - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} FIREBASE_APP_ID: ${{ secrets.FIREBASE_ANDROID_PARENT_APP_ID }} @@ -42,7 +43,8 @@ jobs: app-type-lower: student firebase-app-id-secret: FIREBASE_ANDROID_STUDENT_APP_ID secrets: - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} FIREBASE_APP_ID: ${{ secrets.FIREBASE_ANDROID_STUDENT_APP_ID }} @@ -59,7 +61,8 @@ jobs: app-type-lower: teacher firebase-app-id-secret: FIREBASE_ANDROID_TEACHER_APP_ID secrets: - ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} FIREBASE_APP_ID: ${{ secrets.FIREBASE_ANDROID_TEACHER_APP_ID }} @@ -77,11 +80,20 @@ jobs: contains(github.event.pull_request.body, 'Teacher') ) steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + - name: Checkout repository uses: actions/checkout@v4 with: submodules: 'recursive' - token: ${{ secrets.ACCESS_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -147,12 +159,21 @@ jobs: (github.event.action == 'labeled' && (contains(github.event.pull_request.labels.*.name, 'run-ui-tests') || contains(github.event.pull_request.labels.*.name, 'run-e2e-tests'))) ) && contains(github.event.pull_request.body, 'Parent') steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + - name: Checkout repository uses: actions/checkout@v4 with: submodules: 'recursive' fetch-depth: 1 - token: ${{ secrets.ACCESS_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -201,12 +222,21 @@ jobs: (github.event.action == 'labeled' && (contains(github.event.pull_request.labels.*.name, 'run-ui-tests') || contains(github.event.pull_request.labels.*.name, 'run-e2e-tests'))) ) && contains(github.event.pull_request.body, 'Student') steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + - name: Checkout repository uses: actions/checkout@v4 with: submodules: 'recursive' fetch-depth: 1 - token: ${{ secrets.ACCESS_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -255,12 +285,21 @@ jobs: (github.event.action == 'labeled' && (contains(github.event.pull_request.labels.*.name, 'run-ui-tests') || contains(github.event.pull_request.labels.*.name, 'run-e2e-tests'))) ) && contains(github.event.pull_request.body, 'Teacher') steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + - name: Checkout repository uses: actions/checkout@v4 with: submodules: 'recursive' fetch-depth: 1 - token: ${{ secrets.ACCESS_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -310,12 +349,21 @@ jobs: (github.event.action == 'labeled' && (contains(github.event.pull_request.labels.*.name, 'run-ui-tests') || contains(github.event.pull_request.labels.*.name, 'run-e2e-tests'))) ) && contains(github.event.pull_request.body, 'Student') steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + - name: Checkout repository uses: actions/checkout@v4 with: submodules: 'recursive' fetch-depth: 1 - token: ${{ secrets.ACCESS_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -375,12 +423,21 @@ jobs: (github.event.action == 'labeled' && (contains(github.event.pull_request.labels.*.name, 'run-ui-tests') || contains(github.event.pull_request.labels.*.name, 'run-e2e-tests'))) ) && contains(github.event.pull_request.body, 'Student') steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + - name: Checkout repository uses: actions/checkout@v4 with: submodules: 'recursive' fetch-depth: 1 - token: ${{ secrets.ACCESS_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Set up JDK 17 uses: actions/setup-java@v4 diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 0000000000..9bdee5e832 --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,186 @@ +name: Release Notes + +on: + workflow_dispatch: + inputs: + apps: + description: 'Apps to generate notes for' + type: choice + options: + - all + - student + - teacher + - parent + default: all + +jobs: + student-notes: + name: student-release-notes + runs-on: ubuntu-latest + timeout-minutes: 10 + if: inputs.apps == 'all' || inputs.apps == 'student' + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Fetch tags + run: git fetch --force --tags + + - name: Generate release notes + id: notes + run: | + TAG=$(git tag --list --sort=-version:refname 'student-*' | head -n 1) + if [ -z "$TAG" ]; then + echo "No tags found for student" + exit 1 + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + FULL_OUTPUT=$(node scripts/generate-release-notes.js "$TAG") + NOTES=$(echo "$FULL_OUTPUT" | sed -n '/^Release Notes:$/,$ p' | tail -n +2) + if [ -z "$NOTES" ]; then + NOTES="No release notes found." + fi + echo "notes<> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Post to Slack + uses: slackapi/slack-github-action@v2 + with: + webhook: ${{ secrets.SLACK_URL }} + webhook-type: incoming-webhook + payload: | + { + "text": ":student-app: *Student Release Notes* (${{ steps.notes.outputs.tag }})\n```\n${{ steps.notes.outputs.notes }}\n```" + } + + teacher-notes: + name: teacher-release-notes + runs-on: ubuntu-latest + timeout-minutes: 10 + if: inputs.apps == 'all' || inputs.apps == 'teacher' + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Fetch tags + run: git fetch --force --tags + + - name: Generate release notes + id: notes + run: | + TAG=$(git tag --list --sort=-version:refname 'teacher-*' | head -n 1) + if [ -z "$TAG" ]; then + echo "No tags found for teacher" + exit 1 + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + FULL_OUTPUT=$(node scripts/generate-release-notes.js "$TAG") + NOTES=$(echo "$FULL_OUTPUT" | sed -n '/^Release Notes:$/,$ p' | tail -n +2) + if [ -z "$NOTES" ]; then + NOTES="No release notes found." + fi + echo "notes<> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Post to Slack + uses: slackapi/slack-github-action@v2 + with: + webhook: ${{ secrets.SLACK_URL }} + webhook-type: incoming-webhook + payload: | + { + "text": ":teacher-app: *Teacher Release Notes* (${{ steps.notes.outputs.tag }})\n```\n${{ steps.notes.outputs.notes }}\n```" + } + + parent-notes: + name: parent-release-notes + runs-on: ubuntu-latest + timeout-minutes: 10 + if: inputs.apps == 'all' || inputs.apps == 'parent' + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Fetch tags + run: git fetch --force --tags + + - name: Generate release notes + id: notes + run: | + TAG=$(git tag --list --sort=-version:refname 'parent-*' | head -n 1) + if [ -z "$TAG" ]; then + echo "No tags found for parent" + exit 1 + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + FULL_OUTPUT=$(node scripts/generate-release-notes.js "$TAG") + NOTES=$(echo "$FULL_OUTPUT" | sed -n '/^Release Notes:$/,$ p' | tail -n +2) + if [ -z "$NOTES" ]; then + NOTES="No release notes found." + fi + echo "notes<> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Post to Slack + uses: slackapi/slack-github-action@v2 + with: + webhook: ${{ secrets.SLACK_URL }} + webhook-type: incoming-webhook + payload: | + { + "text": ":parent-app: *Parent Release Notes* (${{ steps.notes.outputs.tag }})\n```\n${{ steps.notes.outputs.notes }}\n```" + } \ No newline at end of file diff --git a/.github/workflows/release-parent.yml b/.github/workflows/release-parent.yml new file mode 100644 index 0000000000..e1769377dd --- /dev/null +++ b/.github/workflows/release-parent.yml @@ -0,0 +1,31 @@ +name: Release Parent + +on: + pull_request: + types: [opened, synchronize] + branches: + - 'release/parent' + +concurrency: + group: release-parent-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + release: + uses: ./.github/workflows/reusable-release-pipeline.yml + with: + app-name: parent + app-display-name: Parent + include-e2e-offline: false + slack-emoji: ':parent-app:' + secrets: + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} + GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }} + SPLUNK_MOBILE_TOKEN: ${{ secrets.SPLUNK_MOBILE_TOKEN }} + OBSERVE_MOBILE_TOKEN: ${{ secrets.OBSERVE_MOBILE_TOKEN }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} + ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD }} + SLACK_URL: ${{ secrets.SLACK_URL }} \ No newline at end of file diff --git a/.github/workflows/release-student.yml b/.github/workflows/release-student.yml new file mode 100644 index 0000000000..bb2039023b --- /dev/null +++ b/.github/workflows/release-student.yml @@ -0,0 +1,31 @@ +name: Release Student + +on: + pull_request: + types: [opened, synchronize] + branches: + - 'release/student' + +concurrency: + group: release-student-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + release: + uses: ./.github/workflows/reusable-release-pipeline.yml + with: + app-name: student + app-display-name: Student + include-e2e-offline: true + slack-emoji: ':student-app:' + secrets: + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} + GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }} + SPLUNK_MOBILE_TOKEN: ${{ secrets.SPLUNK_MOBILE_TOKEN }} + OBSERVE_MOBILE_TOKEN: ${{ secrets.OBSERVE_MOBILE_TOKEN }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} + ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD }} + SLACK_URL: ${{ secrets.SLACK_URL }} \ No newline at end of file diff --git a/.github/workflows/release-teacher.yml b/.github/workflows/release-teacher.yml new file mode 100644 index 0000000000..0ee7f24e73 --- /dev/null +++ b/.github/workflows/release-teacher.yml @@ -0,0 +1,31 @@ +name: Release Teacher + +on: + pull_request: + types: [opened, synchronize] + branches: + - 'release/teacher' + +concurrency: + group: release-teacher-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + release: + uses: ./.github/workflows/reusable-release-pipeline.yml + with: + app-name: teacher + app-display-name: Teacher + include-e2e-offline: false + slack-emoji: ':teacher-app:' + secrets: + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} + GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }} + SPLUNK_MOBILE_TOKEN: ${{ secrets.SPLUNK_MOBILE_TOKEN }} + OBSERVE_MOBILE_TOKEN: ${{ secrets.OBSERVE_MOBILE_TOKEN }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} + ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD }} + SLACK_URL: ${{ secrets.SLACK_URL }} \ No newline at end of file diff --git a/.github/workflows/reusable-release-pipeline.yml b/.github/workflows/reusable-release-pipeline.yml new file mode 100644 index 0000000000..3bb7772de3 --- /dev/null +++ b/.github/workflows/reusable-release-pipeline.yml @@ -0,0 +1,407 @@ +name: Reusable Release Pipeline + +on: + workflow_call: + inputs: + app-name: + description: 'App name (student, teacher, parent)' + required: true + type: string + app-display-name: + description: 'App display name (Student, Teacher, Parent)' + required: true + type: string + include-e2e-offline: + description: 'Include E2E offline tests (Student only)' + required: false + default: false + type: boolean + slack-emoji: + description: 'Slack emoji for the app' + required: true + type: string + secrets: + GH_APP_ID: + required: true + GH_APP_PRIVATE_KEY: + required: true + ANDROID_RELEASE_KEYSTORE_B64: + required: true + GCLOUD_KEY: + required: true + SPLUNK_MOBILE_TOKEN: + required: true + OBSERVE_MOBILE_TOKEN: + required: true + ANDROID_KEYSTORE_PASSWORD: + required: true + ANDROID_KEYSTORE_ALIAS: + required: true + ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD: + required: true + SLACK_URL: + required: true + +jobs: + build-qa-debug: + name: build-qa-debug + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 1 + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + java-version: '17' + + - name: Build QA debug and test APKs + run: | + ./gradle/gradlew -p apps \ + :${{ inputs.app-name }}:assembleQaDebug \ + :${{ inputs.app-name }}:assembleQaDebugAndroidTest \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process + env: + GRADLE_OPTS: "-Djava.net.preferIPv4Stack=true" + + - name: Upload QA debug APK + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.app-name }}-qa-debug.apk + path: apps/${{ inputs.app-name }}/build/outputs/apk/qa/debug/${{ inputs.app-name }}-qa-debug.apk + retention-days: 1 + + - name: Upload QA test APK + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.app-name }}-qa-debug-androidTest.apk + path: apps/${{ inputs.app-name }}/build/outputs/apk/androidTest/qa/debug/${{ inputs.app-name }}-qa-debug-androidTest.apk + retention-days: 1 + + unit-tests: + name: unit-tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 1 + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + java-version: '17' + + - name: Run unit tests + run: | + ./gradle/gradlew -p apps \ + :${{ inputs.app-name }}:testDevDebugUnitTest \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process + env: + GRADLE_OPTS: "-Djava.net.preferIPv4Stack=true" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.app-name }}-unit-test-results + path: | + apps/${{ inputs.app-name }}/build/reports/tests/testDevDebugUnitTest/ + apps/${{ inputs.app-name }}/build/test-results/testDevDebugUnitTest/ + retention-days: 1 + + flank-tests: + name: flank-${{ matrix.test-type }} + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: build-qa-debug + strategy: + fail-fast: false + matrix: + include: + - test-type: portrait + flank-config: flank.yml + - test-type: landscape + flank-config: flank_landscape.yml + - test-type: tablet + flank-config: flank_tablet.yml + - test-type: e2e + flank-config: flank_e2e.yml + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Setup Service account + env: + GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${GCLOUD_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${GCLOUD_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/${{ inputs.app-name }}/${{ matrix.flank-config }} ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "${{ inputs.app-name }}-qa-debug.apk" ]; then + mkdir -p apps/${{ inputs.app-name }}/build/outputs/apk/qa/debug + mv ${{ inputs.app-name }}-qa-debug.apk/${{ inputs.app-name }}-qa-debug.apk apps/${{ inputs.app-name }}/build/outputs/apk/qa/debug/ + rm -rf ${{ inputs.app-name }}-qa-debug.apk + fi + if [ -d "${{ inputs.app-name }}-qa-debug-androidTest.apk" ]; then + mkdir -p apps/${{ inputs.app-name }}/build/outputs/apk/androidTest/qa/debug + mv ${{ inputs.app-name }}-qa-debug-androidTest.apk/${{ inputs.app-name }}-qa-debug-androidTest.apk apps/${{ inputs.app-name }}/build/outputs/apk/androidTest/qa/debug/ + rm -rf ${{ inputs.app-name }}-qa-debug-androidTest.apk + fi + + - name: Run Flank tests + uses: Flank/flank@v23.10.1 + with: + version: 'v23.07.0' + platform: 'android' + service_account: './service-account-key.json' + flank_configuration_file: './flank.yml' + + - name: Report test results + if: always() + run: | + if [ -d results ] && [ "$(ls results 2>/dev/null | wc -l)" -eq 1 ]; then + ./apps/postProcessTestRun.bash ${{ inputs.app-name }} results/$(ls results) + else + echo "Warning: Expected exactly one results directory, found: $(ls results 2>/dev/null || echo 'none')" + fi + env: + SPLUNK_MOBILE_TOKEN: ${{ secrets.SPLUNK_MOBILE_TOKEN }} + OBSERVE_MOBILE_TOKEN: ${{ secrets.OBSERVE_MOBILE_TOKEN }} + BITRISE_TRIGGERED_WORKFLOW_ID: ${{ github.workflow }} + BITRISE_GIT_BRANCH: ${{ github.head_ref || github.ref_name }} + BITRISE_BUILD_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + - name: Cleanup sensitive files + if: always() + run: rm -f service-account-key.json + + e2e-offline: + name: e2e-offline + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: build-qa-debug + if: ${{ inputs.include-e2e-offline }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Download artifacts + uses: actions/download-artifact@v4 + + - name: Setup Service account + env: + GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${GCLOUD_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${GCLOUD_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/${{ inputs.app-name }}/flank_e2e_offline.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "${{ inputs.app-name }}-qa-debug.apk" ]; then + mkdir -p apps/${{ inputs.app-name }}/build/outputs/apk/qa/debug + mv ${{ inputs.app-name }}-qa-debug.apk/${{ inputs.app-name }}-qa-debug.apk apps/${{ inputs.app-name }}/build/outputs/apk/qa/debug/ + rm -rf ${{ inputs.app-name }}-qa-debug.apk + fi + if [ -d "${{ inputs.app-name }}-qa-debug-androidTest.apk" ]; then + mkdir -p apps/${{ inputs.app-name }}/build/outputs/apk/androidTest/qa/debug + mv ${{ inputs.app-name }}-qa-debug-androidTest.apk/${{ inputs.app-name }}-qa-debug-androidTest.apk apps/${{ inputs.app-name }}/build/outputs/apk/androidTest/qa/debug/ + rm -rf ${{ inputs.app-name }}-qa-debug-androidTest.apk + fi + + - name: Run Flank E2E offline tests + uses: Flank/flank@v23.10.1 + with: + version: 'v23.07.0' + platform: 'android' + service_account: './service-account-key.json' + flank_configuration_file: './flank.yml' + + - name: Report test results + if: always() + run: | + if [ -d results ] && [ "$(ls results 2>/dev/null | wc -l)" -eq 1 ]; then + ./apps/postProcessTestRun.bash ${{ inputs.app-name }} results/$(ls results) + else + echo "Warning: Expected exactly one results directory, found: $(ls results 2>/dev/null || echo 'none')" + fi + env: + SPLUNK_MOBILE_TOKEN: ${{ secrets.SPLUNK_MOBILE_TOKEN }} + OBSERVE_MOBILE_TOKEN: ${{ secrets.OBSERVE_MOBILE_TOKEN }} + BITRISE_TRIGGERED_WORKFLOW_ID: ${{ github.workflow }} + BITRISE_GIT_BRANCH: ${{ github.head_ref || github.ref_name }} + BITRISE_BUILD_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + - name: Cleanup sensitive files + if: always() + run: rm -f service-account-key.json + + build-release-candidate: + name: build-release-candidate + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: [unit-tests, flank-tests, e2e-offline] + if: | + always() && + needs.unit-tests.result == 'success' && + needs.flank-tests.result == 'success' && + (needs.e2e-offline.result == 'success' || needs.e2e-offline.result == 'skipped') + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: canvas-android,android-vault + + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 1 + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + java-version: '17' + + - name: Decode release keystore + env: + KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} + run: | + echo "$KEYSTORE_B64" | base64 --decode > release.jks + chmod 600 release.jks + + - name: Build release candidate + run: | + ./gradle/gradlew -p apps \ + :${{ inputs.app-name }}:assembleProdRelease \ + :${{ inputs.app-name }}:bundleProdRelease \ + --stacktrace \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process \ + -Pandroid.injected.signing.store.file=$(pwd)/release.jks \ + -Pandroid.injected.signing.store.password="${KEYSTORE_PASSWORD}" \ + -Pandroid.injected.signing.key.alias="${KEYSTORE_KEY_ALIAS}" \ + -Pandroid.injected.signing.key.password="${KEYSTORE_KEY_PASSWORD}" + env: + GRADLE_OPTS: "-Djava.net.preferIPv4Stack=true" + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} + KEYSTORE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD }} + + - name: Upload release APK + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.app-name }}-prod-release.apk + path: apps/${{ inputs.app-name }}/build/outputs/apk/prod/release/${{ inputs.app-name }}-prod-release.apk + retention-days: 7 + + - name: Upload release AAB + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.app-name }}-prod-release.aab + path: apps/${{ inputs.app-name }}/build/outputs/bundle/prodRelease/${{ inputs.app-name }}-prod-release.aab + retention-days: 7 + + - name: Cleanup sensitive files + if: always() + run: rm -f release.jks + + slack-notification: + name: slack-notification + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [build-release-candidate] + if: always() + steps: + - name: Notify Slack on success + if: needs.build-release-candidate.result == 'success' + uses: slackapi/slack-github-action@v2 + with: + webhook: ${{ secrets.SLACK_URL }} + webhook-type: incoming-webhook + payload: | + { + "text": ":android: ${{ inputs.slack-emoji }} Android ${{ inputs.app-display-name }} RC Available :android:\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Download artifacts from GitHub Actions>" + } + + - name: Notify Slack on failure + if: needs.build-release-candidate.result != 'success' + uses: slackapi/slack-github-action@v2 + with: + webhook: ${{ secrets.SLACK_URL }} + webhook-type: incoming-webhook + payload: | + { + "text": ":x: ${{ inputs.slack-emoji }} Android ${{ inputs.app-display-name }} Release Pipeline Failed\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View failed run>" + } \ No newline at end of file diff --git a/apps/buildSrc/src/main/java/GlobalDependencies.kt b/apps/buildSrc/src/main/java/GlobalDependencies.kt index 51c7f0ffd3..19d8833326 100644 --- a/apps/buildSrc/src/main/java/GlobalDependencies.kt +++ b/apps/buildSrc/src/main/java/GlobalDependencies.kt @@ -25,7 +25,7 @@ object Versions { /* Others */ const val APOLLO = "4.3.3" - const val NUTRIENT = "10.7.0" + const val NUTRIENT = "11.1.1" const val PHOTO_VIEW = "2.3.0" const val MOBIUS = "1.2.1" const val HILT = "2.57.2" @@ -47,6 +47,7 @@ object Versions { const val GLANCE = "1.1.1" const val LIVEDATA = "1.9.0" const val REORDERABLE = "2.4.0" + const val MLKIT_DOCUMENT_SCANNER = "16.0.0" } object Libs { @@ -95,6 +96,7 @@ object Libs { const val PLAY_IN_APP_UPDATES = "com.google.android.play:app-update:2.1.0" const val FLEXBOX_LAYOUT = "com.google.android.flexbox:flexbox:3.0.0" const val MATERIAL_DESIGN = "com.google.android.material:material:1.13.0" + const val MLKIT_DOCUMENT_SCANNER = "com.google.android.gms:play-services-mlkit-document-scanner:${Versions.MLKIT_DOCUMENT_SCANNER}" /* Mobius */ const val MOBIUS_CORE = "com.spotify.mobius:mobius-core:${Versions.MOBIUS}" diff --git a/apps/parent/build.gradle b/apps/parent/build.gradle index d5bffc8112..5b7b0d4292 100644 --- a/apps/parent/build.gradle +++ b/apps/parent/build.gradle @@ -41,8 +41,8 @@ android { applicationId "com.instructure.parentapp" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode 67 - versionName "4.10.1" + versionCode 68 + versionName "4.11.0" buildConfigField "boolean", "IS_TESTING", "false" testInstrumentationRunner 'com.instructure.parentapp.ui.espresso.ParentHiltTestRunner' diff --git a/apps/parent/src/androidTest/assets/samplepdf.pdf b/apps/parent/src/androidTest/assets/samplepdf.pdf new file mode 100644 index 0000000000..1693fcb951 Binary files /dev/null and b/apps/parent/src/androidTest/assets/samplepdf.pdf differ diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/AlertsE2ETest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/AlertsE2ETest.kt index 862af1d795..fa19e001e0 100644 --- a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/AlertsE2ETest.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/AlertsE2ETest.kt @@ -442,4 +442,172 @@ class AlertsE2ETest : ParentComposeTest() { alertsPage.assertAlertRead("Course grade: 90.0% in ${course.courseCode}") } + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.ALERTS, TestCategory.E2E) + fun testAlertSettingsMarkBelowAboveDependencyE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, courses = 1, teachers = 1, parents = 1) + val parent = data.parentsList[0] + val student = data.studentsList[0] + + Log.d(STEP_TAG, "Login with user: '${parent.name}', login id: '${parent.loginId}'.") + tokenLogin(parent) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the Left Side Navigation Drawer menu.") + dashboardPage.openLeftSideMenu() + + Log.d(STEP_TAG, "Open the Manage Students Page.") + leftSideNavigationDrawerPage.clickManageStudents() + + Log.d(STEP_TAG, "Open the Student Alert Settings Page.") + manageStudentsPage.clickStudent(student.shortName) + + val assignmentGradeBelow = "Assignment grade below" + val assignmentGradeAbove = "Assignment grade above" + Log.d(STEP_TAG, "Set '$assignmentGradeBelow' threshold to 30%.") + studentAlertSettingsPage.setThreshold(AlertType.ASSIGNMENT_GRADE_LOW, "30") + + Log.d(ASSERTION_TAG, "Assert that '$assignmentGradeBelow' threshold is set to '30%'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.ASSIGNMENT_GRADE_LOW, "30%") + + Log.d(STEP_TAG, "Open the '$assignmentGradeAbove' dialog and enter '20' (below the low threshold of 30).") + studentAlertSettingsPage.clickThreshold(AlertType.ASSIGNMENT_GRADE_HIGH) + studentAlertSettingsPage.enterThreshold("20") + + Log.d(ASSERTION_TAG, "Assert that an error is shown because 20 is not above the '$assignmentGradeBelow' threshold of 30.") + studentAlertSettingsPage.assertThresholdDialogError() + + Log.d(STEP_TAG, "Change the '$assignmentGradeAbove' threshold value to '70'.") + studentAlertSettingsPage.enterThreshold("70") + + Log.d(ASSERTION_TAG, "Assert that there is no error for '$assignmentGradeAbove' value 70.") + studentAlertSettingsPage.assertThresholdDialogNotError() + + Log.d(STEP_TAG, "Save the '$assignmentGradeAbove' threshold.") + studentAlertSettingsPage.tapThresholdSaveButton() + + Log.d(ASSERTION_TAG, "Assert that '$assignmentGradeAbove' threshold is saved as '70%'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.ASSIGNMENT_GRADE_HIGH, "70%") + + Log.d(STEP_TAG, "Open the '$assignmentGradeBelow' dialog and enter '80' (above the '$assignmentGradeAbove' threshold of 70).") + studentAlertSettingsPage.clickThreshold(AlertType.ASSIGNMENT_GRADE_LOW) + studentAlertSettingsPage.enterThreshold("80") + + Log.d(ASSERTION_TAG, "Assert that an error is shown because 80 is not below the '$assignmentGradeAbove' threshold of 70.") + studentAlertSettingsPage.assertThresholdDialogError() + + Log.d(STEP_TAG, "Change the '$assignmentGradeBelow' threshold value to '50'.") + studentAlertSettingsPage.enterThreshold("50") + + Log.d(ASSERTION_TAG, "Assert that there is no error for '$assignmentGradeBelow' value 50.") + studentAlertSettingsPage.assertThresholdDialogNotError() + + Log.d(STEP_TAG, "Save the '$assignmentGradeBelow' threshold.") + studentAlertSettingsPage.tapThresholdSaveButton() + + Log.d(ASSERTION_TAG, "Assert that '$assignmentGradeBelow' threshold is saved as '50%'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.ASSIGNMENT_GRADE_LOW, "50%") + + Log.d(STEP_TAG, "Set '$assignmentGradeBelow' to Never so the '$assignmentGradeAbove' threshold has no lower bound.") + studentAlertSettingsPage.clickThreshold(AlertType.ASSIGNMENT_GRADE_LOW) + studentAlertSettingsPage.tapThresholdNeverButton() + + Log.d(ASSERTION_TAG, "Assert that '$assignmentGradeBelow' threshold is set to 'Never'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.ASSIGNMENT_GRADE_LOW, "Never") + + Log.d(STEP_TAG, "Open the '$assignmentGradeAbove' dialog and enter '100' (boundary value that is not allowed).") + studentAlertSettingsPage.clickThreshold(AlertType.ASSIGNMENT_GRADE_HIGH) + studentAlertSettingsPage.enterThreshold("100") + + Log.d(ASSERTION_TAG, "Assert that an error is shown because 100 is not a valid '$assignmentGradeAbove' threshold (max is exclusive).") + studentAlertSettingsPage.assertThresholdDialogError() + + Log.d(STEP_TAG, "Change the '$assignmentGradeAbove' threshold value to '99' (the maximum valid value).") + studentAlertSettingsPage.enterThreshold("99") + + Log.d(ASSERTION_TAG, "Assert that there is no error for '$assignmentGradeAbove' value 99.") + studentAlertSettingsPage.assertThresholdDialogNotError() + + Log.d(STEP_TAG, "Save the '$assignmentGradeAbove' threshold.") + studentAlertSettingsPage.tapThresholdSaveButton() + + Log.d(ASSERTION_TAG, "Assert that '$assignmentGradeAbove' threshold is saved as '99%'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.ASSIGNMENT_GRADE_HIGH, "99%") + + val courseGradeBelow = "Course grade below" + val courseGradeAbove = "Course grade above" + Log.d(STEP_TAG, "Set '$courseGradeBelow' threshold to 30%.") + studentAlertSettingsPage.setThreshold(AlertType.COURSE_GRADE_LOW, "30") + + Log.d(ASSERTION_TAG, "Assert that '$courseGradeBelow' threshold is set to '30%'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.COURSE_GRADE_LOW, "30%") + + Log.d(STEP_TAG, "Open the '$courseGradeAbove' dialog and enter '20' (below the low threshold of 30).") + studentAlertSettingsPage.clickThreshold(AlertType.COURSE_GRADE_HIGH) + studentAlertSettingsPage.enterThreshold("20") + + Log.d(ASSERTION_TAG, "Assert that an error is shown because 20 is not above the '$courseGradeBelow' threshold of 30.") + studentAlertSettingsPage.assertThresholdDialogError() + + Log.d(STEP_TAG, "Change the '$courseGradeAbove' threshold value to '70'.") + studentAlertSettingsPage.enterThreshold("70") + + Log.d(ASSERTION_TAG, "Assert that there is no error for '$courseGradeAbove' value 70.") + studentAlertSettingsPage.assertThresholdDialogNotError() + + Log.d(STEP_TAG, "Save the '$courseGradeAbove' threshold.") + studentAlertSettingsPage.tapThresholdSaveButton() + + Log.d(ASSERTION_TAG, "Assert that '$courseGradeAbove' threshold is saved as '70%'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.COURSE_GRADE_HIGH, "70%") + + Log.d(STEP_TAG, "Open the '$courseGradeBelow' dialog and enter '80' (above the '$courseGradeAbove' threshold of 70).") + studentAlertSettingsPage.clickThreshold(AlertType.COURSE_GRADE_LOW) + studentAlertSettingsPage.enterThreshold("80") + + Log.d(ASSERTION_TAG, "Assert that an error is shown because 80 is not below the '$courseGradeAbove' threshold of 70.") + studentAlertSettingsPage.assertThresholdDialogError() + + Log.d(STEP_TAG, "Change the '$courseGradeBelow' threshold value to '50'.") + studentAlertSettingsPage.enterThreshold("50") + + Log.d(ASSERTION_TAG, "Assert that there is no error for '$courseGradeBelow' value 50.") + studentAlertSettingsPage.assertThresholdDialogNotError() + + Log.d(STEP_TAG, "Save the '$courseGradeBelow' threshold.") + studentAlertSettingsPage.tapThresholdSaveButton() + + Log.d(ASSERTION_TAG, "Assert that '$courseGradeBelow' threshold is saved as '50%'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.COURSE_GRADE_LOW, "50%") + + Log.d(STEP_TAG, "Set '$courseGradeBelow' to Never so the '$courseGradeAbove' threshold has no lower bound.") + studentAlertSettingsPage.clickThreshold(AlertType.COURSE_GRADE_LOW) + studentAlertSettingsPage.tapThresholdNeverButton() + + Log.d(ASSERTION_TAG, "Assert that '$courseGradeBelow' threshold is set to 'Never'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.COURSE_GRADE_LOW, "Never") + + Log.d(STEP_TAG, "Open the '$courseGradeAbove' dialog and enter '100' (boundary value that is not allowed).") + studentAlertSettingsPage.clickThreshold(AlertType.COURSE_GRADE_HIGH) + studentAlertSettingsPage.enterThreshold("100") + + Log.d(ASSERTION_TAG, "Assert that an error is shown because 100 is not a valid '$courseGradeAbove' threshold (max is exclusive).") + studentAlertSettingsPage.assertThresholdDialogError() + + Log.d(STEP_TAG, "Change the '$courseGradeAbove' threshold value to '99' (the maximum valid value).") + studentAlertSettingsPage.enterThreshold("99") + + Log.d(ASSERTION_TAG, "Assert that there is no error for '$courseGradeAbove' value 99.") + studentAlertSettingsPage.assertThresholdDialogNotError() + + Log.d(STEP_TAG, "Save the '$courseGradeAbove' threshold.") + studentAlertSettingsPage.tapThresholdSaveButton() + + Log.d(ASSERTION_TAG, "Assert that '$courseGradeAbove' threshold is saved as '99%'.") + studentAlertSettingsPage.assertPercentageThreshold(AlertType.COURSE_GRADE_HIGH, "99%") + } + } \ No newline at end of file diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CalendarE2ETest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CalendarE2ETest.kt index 8fc0ce2347..9f533eef91 100644 --- a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CalendarE2ETest.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CalendarE2ETest.kt @@ -181,12 +181,12 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the student selector is displayed and the selected student is that the one is the first ordered by 'sortableName'.") dashboardPage.assertSelectedStudent(selectedShortName) - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo Title" val testTodoDescription = "Details of ToDo" @@ -196,26 +196,26 @@ class CalendarE2ETest : ParentComposeTest() { calendarToDoCreateUpdatePage.clickSave() val currentDate = getDateInCanvasCalendarFormat() - Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously created To Do item is displayed with the corresponding title, context and date.") - calendarScreenPage.assertItemDetails(testTodoTitle, "To Do", "$currentDate at 12:00 PM") + Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously created To-do item is displayed with the corresponding title, context and date.") + calendarScreenPage.assertItemDetails(testTodoTitle, "To-do", "$currentDate at 12:00 PM") - Log.d(STEP_TAG, "Clicks on the '$testTodoTitle' To Do item.") + Log.d(STEP_TAG, "Clicks on the '$testTodoTitle' To-do item.") calendarScreenPage.clickOnItem(testTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle', the context is 'To Do', the date is the current day with 12:00 PM time and the description is '$testTodoDescription'.") + Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle', the context is 'To-do', the date is the current day with 12:00 PM time and the description is '$testTodoDescription'.") calendarToDoDetailsPage.assertTitle(testTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") calendarToDoDetailsPage.assertDate("$currentDate at 12:00 PM") calendarToDoDetailsPage.assertDescription(testTodoDescription) - Log.d(STEP_TAG, "Click on the 'Edit To Do' within the toolbar more menu and confirm the editing.") + Log.d(STEP_TAG, "Click on the 'Edit To-do' within the toolbar more menu and confirm the editing.") calendarToDoDetailsPage.clickToolbarMenu() calendarToDoDetailsPage.clickEditMenu() - Log.d(ASSERTION_TAG, "Assert that the page title is 'Edit To Do' as we are editing an existing To Do item.") - calendarToDoCreateUpdatePage.assertPageTitle("Edit To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'Edit To-do' as we are editing an existing To-do item.") + calendarToDoCreateUpdatePage.assertPageTitle("Edit To-do") - Log.d(ASSERTION_TAG, "Assert that the 'original' To Do Title and details has been filled into the input fields as we on the edit screen.") + Log.d(ASSERTION_TAG, "Assert that the 'original' To-do Title and details has been filled into the input fields as we on the edit screen.") calendarToDoCreateUpdatePage.assertTodoTitle(testTodoTitle) calendarToDoCreateUpdatePage.assertDetails(testTodoDescription) @@ -226,19 +226,19 @@ class CalendarE2ETest : ParentComposeTest() { calendarToDoCreateUpdatePage.typeDetails(modifiedTestTodoDescription) calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously modified To Do item is displayed with the corresponding title, context and with the same date as we haven't changed it.") - calendarScreenPage.assertItemDetails(modifiedTestTodoTitle, "To Do", "$currentDate at 12:00 PM") + Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously modified To-do item is displayed with the corresponding title, context and with the same date as we haven't changed it.") + calendarScreenPage.assertItemDetails(modifiedTestTodoTitle, "To-do", "$currentDate at 12:00 PM") - Log.d(STEP_TAG, "Clicks on the '$modifiedTestTodoTitle' To Do item.") + Log.d(STEP_TAG, "Clicks on the '$modifiedTestTodoTitle' To-do item.") calendarScreenPage.clickOnItem(modifiedTestTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the To Do title is '$modifiedTestTodoTitle', the page title is 'To Do', the date remained current day with 12:00 PM time (as we haven't modified it) and the description is '$modifiedTestTodoDescription'.") + Log.d(ASSERTION_TAG, "Assert that the To-do title is '$modifiedTestTodoTitle', the page title is 'To-do', the date remained current day with 12:00 PM time (as we haven't modified it) and the description is '$modifiedTestTodoDescription'.") calendarToDoDetailsPage.assertTitle(modifiedTestTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") calendarToDoDetailsPage.assertDate("$currentDate at 12:00 PM") calendarToDoDetailsPage.assertDescription(modifiedTestTodoDescription) - Log.d(STEP_TAG, "Click on the 'Delete To Do' within the toolbar more menu and confirm the deletion.") + Log.d(STEP_TAG, "Click on the 'Delete To-do' within the toolbar more menu and confirm the deletion.") calendarToDoDetailsPage.clickToolbarMenu() calendarToDoDetailsPage.clickDeleteMenu() calendarToDoDetailsPage.confirmDeletion() @@ -246,7 +246,7 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the deleted item does not exist anymore on the Calendar Screen Page.") calendarScreenPage.assertItemNotExist(modifiedTestTodoTitle) - Log.d(ASSERTION_TAG, "Assert that after the deletion the empty view will be displayed since we don't have any To Do items on the current day.") + Log.d(ASSERTION_TAG, "Assert that after the deletion the empty view will be displayed since we don't have any To-do items on the current day.") calendarScreenPage.assertEmptyView() } @@ -297,12 +297,12 @@ class CalendarE2ETest : ParentComposeTest() { calendarScreenPage.assertItemDetails(newEventTitle, parent.name, currentDate) Thread.sleep(2000) - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo Title" val testTodoDescription = "Details of ToDo" @@ -318,7 +318,7 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Click on the 'Save' button.") calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To Do item is displayed because we created it to this particular day. " + + Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To-do item is displayed because we created it to this particular day. " + "Assert that '$newEventTitle' calendar event is NOT displayed because it's created for today.") calendarScreenPage.assertItemDisplayed(testTodoTitle) calendarScreenPage.assertItemNotExist(newEventTitle) @@ -330,7 +330,7 @@ class CalendarE2ETest : ParentComposeTest() { calendarFilterPage.clickOnFilterItem(course.name) calendarFilterPage.closeFilterPage() - Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To Do item is NOT displayed because we filtered out from the calendar. " + + Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To-do item is NOT displayed because we filtered out from the calendar. " + "Assert that the empty view is displayed because there are no items for today.") calendarScreenPage.assertItemNotExist(testTodoTitle) calendarScreenPage.assertEmptyView() @@ -690,12 +690,12 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Click on the 'Calendar' bottom menu to navigate to the Calendar page.") dashboardPage.clickCalendarBottomMenu() - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo With Reminder" val testTodoDescription = "Details of ToDo" @@ -710,15 +710,15 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Click on the 'Save' button.") calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) - Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To Do item.") + Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To-do item.") calendarScreenPage.clickOnItem(testTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To Do'.") + Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To-do'.") calendarToDoDetailsPage.assertTitle(testTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") Log.d(ASSERTION_TAG, "Assert that the reminder section is displayed.") calendarToDoDetailsPage.assertReminderSectionDisplayed() @@ -732,7 +732,7 @@ class CalendarE2ETest : ParentComposeTest() { calendarToDoDetailsPage.selectDate(reminderDateOneHour) calendarToDoDetailsPage.selectTime(reminderDateOneHour) - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneHour.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -762,7 +762,7 @@ class CalendarE2ETest : ParentComposeTest() { calendarToDoDetailsPage.selectDate(reminderDateOneWeek) calendarToDoDetailsPage.selectTime(reminderDateOneWeek) - Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To Do which is due in 2 days).") + Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To-do which is due in 2 days).") calendarToDoDetailsPage.assertReminderNotDisplayedWithText(reminderDateOneWeek.time.toFormattedString()) checkToastText(R.string.reminderInPast, activityRule.activity) futureDate.apply { add(Calendar.WEEK_OF_YEAR, 1) } @@ -776,7 +776,7 @@ class CalendarE2ETest : ParentComposeTest() { calendarToDoDetailsPage.selectDate(reminderDateOneDay) calendarToDoDetailsPage.selectTime(reminderDateOneDay) - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneDay.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -794,7 +794,7 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Navigate back to Calendar Screen Page.") Espresso.pressBack() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) } @@ -818,12 +818,12 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Click on the 'Calendar' bottom menu to navigate to the Calendar page.") dashboardPage.clickCalendarBottomMenu() - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo With Reminder" val testTodoDescription = "Details of ToDo" @@ -838,15 +838,15 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Click on the 'Save' button.") calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) - Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To Do item.") + Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To-do item.") calendarScreenPage.clickOnItem(testTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To Do'.") + Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To-do'.") calendarToDoDetailsPage.assertTitle(testTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") Log.d(ASSERTION_TAG, "Assert that the reminder section is displayed.") calendarToDoDetailsPage.assertReminderSectionDisplayed() @@ -858,7 +858,7 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Select '1 Hour Before'.") calendarToDoDetailsPage.clickBeforeReminderOption("1 Hour Before") - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneHour.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -884,7 +884,7 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Select '1 Week Before'.") calendarToDoDetailsPage.clickBeforeReminderOption("1 Week Before") - Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To Do which is due in 2 days).") + Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To-do which is due in 2 days).") calendarToDoDetailsPage.assertReminderNotDisplayedWithText(reminderDateOneWeek.time.toFormattedString()) checkToastText(R.string.reminderInPast, activityRule.activity) futureDate.apply { add(Calendar.WEEK_OF_YEAR, 1) } @@ -896,7 +896,7 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Select '1 Day Before'.") calendarToDoDetailsPage.clickBeforeReminderOption("1 Day Before") - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneDay.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -912,7 +912,7 @@ class CalendarE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Navigate back to Calendar Screen Page.") Espresso.pressBack() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) } } diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/DiscussionCheckpointsE2ETest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/DiscussionCheckpointsE2ETest.kt new file mode 100644 index 0000000000..0263cf2287 --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/DiscussionCheckpointsE2ETest.kt @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2026 - present Instructure, 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. + */ +package com.instructure.parentapp.ui.e2e.compose + +import android.util.Log +import androidx.test.espresso.Espresso +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData +import com.instructure.canvas.espresso.annotations.E2E +import com.instructure.dataseeding.api.DiscussionTopicsApi +import com.instructure.espresso.getCustomDateCalendar +import com.instructure.pandautils.features.calendar.CalendarPrefs +import com.instructure.parentapp.utils.ParentComposeTest +import com.instructure.parentapp.utils.extensions.seedData +import com.instructure.parentapp.utils.extensions.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +@HiltAndroidTest +class DiscussionCheckpointsE2ETest : ParentComposeTest() { + + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @Before + fun clearPreferences() { + CalendarPrefs.clearPrefs() + } + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DISCUSSIONS, TestCategory.E2E, SecondaryFeatureCategory.DISCUSSION_CHECKPOINTS) + fun testDiscussionCheckpointsCalendarE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, parents = 1, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val parent = data.parentsList[0] + val course = data.coursesList[0] + + val discussionWithCheckpointsTitle = "Test Discussion with Checkpoints" + val assignmentName = "Test Assignment with Checkpoints" + + Log.d(PREPARATION_TAG, "Convert dates to match with different formats in different screens (Calendar, Assignment Details)") + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val calendarDisplayFormat = SimpleDateFormat(" MMM d 'at' h:mm a", Locale.US) + val assignmentDetailsDisplayFormat = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.US) + val replyToTopicCalendar = getCustomDateCalendar(2) + val replyToEntryCalendar = getCustomDateCalendar(4) + val replyToTopicDueDate = dateFormat.format(replyToTopicCalendar.time) + val replyToEntryDueDate = dateFormat.format(replyToEntryCalendar.time) + val assignmentDetailsReplyToTopicDueDate = assignmentDetailsDisplayFormat.format(replyToTopicCalendar.time) + val assignmentDetailsReplyToEntryDueDate = assignmentDetailsDisplayFormat.format(replyToEntryCalendar.time) + val convertedReplyToTopicDueDate = calendarDisplayFormat.format(replyToTopicCalendar.time) + val convertedReplyToEntryDueDate = calendarDisplayFormat.format(replyToEntryCalendar.time) + + Log.d(PREPARATION_TAG, "Seed a discussion topic with checkpoints for '${course.name}' course.") + DiscussionTopicsApi.createDiscussionTopicWithCheckpoints(course.id, teacher.token, discussionWithCheckpointsTitle, assignmentName, replyToTopicDueDate, replyToEntryDueDate) + + Log.d(STEP_TAG, "Login with user: '${parent.name}', login id: '${parent.loginId}'.") + tokenLogin(parent) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Click on the 'Calendar' bottom menu to navigate to the Calendar page.") + dashboardPage.clickCalendarBottomMenu() + composeTestRule.waitForIdle() + + Log.d(STEP_TAG , "Swipe 2 days to the future to find the 'Reply to Topic' Discussion Checkpoint calendar item.") + calendarScreenPage.swipeEventsLeft(2) + + Log.d(ASSERTION_TAG, "Assert that the '$discussionWithCheckpointsTitle Reply to Topic' checkpoint is displayed on its due date on the Calendar Page.") + calendarScreenPage.assertItemDetails("$discussionWithCheckpointsTitle Reply to Topic", course.name, "Due$convertedReplyToTopicDueDate") + + Log.d(STEP_TAG, "Select the '$discussionWithCheckpointsTitle Reply to Topic' event and navigate back to the Calendar Page.") + calendarScreenPage.clickOnItem("$discussionWithCheckpointsTitle Reply to Topic") + + Log.d(ASSERTION_TAG, "Assert that the Assignment Details Page is displayed properly with the correct toolbar title and subtitle.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertDisplayToolbarSubtitle(course.name) + + Log.d(ASSERTION_TAG, "Assert that the checkpoints are displayed properly on the Assignment Details Page.") + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Reply to topic due", assignmentDetailsReplyToTopicDueDate) + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Additional replies (2) due", assignmentDetailsReplyToEntryDueDate) + + Log.d(STEP_TAG, "Navigate back to the Calendar Page.") + Espresso.pressBack() + + Log.d(STEP_TAG , "Swipe 2 additional days to the future to find the 'Additional replies' Discussion Checkpoint calendar item.") + calendarScreenPage.swipeEventsLeft(2) + + Log.d(ASSERTION_TAG, "Assert that the '$discussionWithCheckpointsTitle Required Replies (2)' checkpoint is displayed on its due date on the Calendar Page.") + calendarScreenPage.assertItemDetails("$discussionWithCheckpointsTitle Required Replies (2)", course.name, "Due$convertedReplyToEntryDueDate") + + Log.d(STEP_TAG, "Select the '$discussionWithCheckpointsTitle Required Replies (2)' event and navigate back to the Calendar Page.") + calendarScreenPage.clickOnItem("$discussionWithCheckpointsTitle Required Replies (2)") + + Log.d(ASSERTION_TAG, "Assert that the Assignment Details Page is displayed properly with the correct toolbar title and subtitle.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertDisplayToolbarSubtitle(course.name) + + Log.d(ASSERTION_TAG, "Assert that the checkpoints are displayed properly on the Assignment Details Page.") + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Reply to topic due", assignmentDetailsReplyToTopicDueDate) + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Additional replies (2) due", assignmentDetailsReplyToEntryDueDate) + + Log.d(STEP_TAG, "Navigate back to the Calendar Page.") + Espresso.pressBack() + + Log.d(STEP_TAG, "Navigate back 2 days to the 'Reply to Topic' checkpoint due date.") + calendarScreenPage.swipeEventsRight(2) + + Log.d(ASSERTION_TAG, "Assert that the '$discussionWithCheckpointsTitle Reply to Topic' checkpoint is displayed on its due date on the Calendar Page.") + calendarScreenPage.assertItemDetails("$discussionWithCheckpointsTitle Reply to Topic", course.name, "Due$convertedReplyToTopicDueDate") + + Log.d(STEP_TAG, "Select the '$discussionWithCheckpointsTitle Reply to Topic' event and navigate back to the Calendar Page.") + calendarScreenPage.clickOnItem("$discussionWithCheckpointsTitle Reply to Topic") + + Log.d(ASSERTION_TAG, "Assert that the Assignment Details Page is displayed properly with the correct toolbar title and subtitle.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertDisplayToolbarSubtitle(course.name) + + Log.d(ASSERTION_TAG, "Assert that the checkpoints are displayed properly on the Assignment Details Page.") + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Reply to topic due", assignmentDetailsReplyToTopicDueDate) + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Additional replies (2) due", assignmentDetailsReplyToEntryDueDate) + } + +} diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/DiscussionsE2ETest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/DiscussionsE2ETest.kt new file mode 100644 index 0000000000..42c47cfcf3 --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/DiscussionsE2ETest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2026 - present Instructure, 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. + * + */ +package com.instructure.parentapp.ui.e2e.compose + +import android.os.SystemClock.sleep +import android.util.Log +import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import com.instructure.canvas.espresso.FeatureCategory +import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory +import com.instructure.canvas.espresso.TestCategory +import com.instructure.canvas.espresso.TestMetaData +import com.instructure.canvas.espresso.annotations.E2E +import com.instructure.dataseeding.api.DiscussionTopicsApi +import com.instructure.dataseeding.api.FileFolderApi +import com.instructure.dataseeding.api.FileUploadsApi +import com.instructure.dataseeding.model.FileUploadType +import com.instructure.espresso.convertIso8601ToCanvasFormat +import com.instructure.espresso.retryWithIncreasingDelay +import com.instructure.parentapp.utils.ParentComposeTest +import com.instructure.parentapp.utils.extensions.seedData +import com.instructure.parentapp.utils.extensions.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@HiltAndroidTest +class DiscussionsE2ETest: ParentComposeTest() { + + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DISCUSSIONS, TestCategory.E2E, SecondaryFeatureCategory.DISCUSSION_CHECKPOINTS) + fun testDiscussionCheckpointWithPdfAttachmentE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1, parents = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val parent = data.parentsList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Get course root folder to upload the PDF file.") + val courseRootFolder = FileFolderApi.getCourseRootFolder(course.id, teacher.token) + + Log.d(PREPARATION_TAG, "Read PDF file from assets.") + val pdfFileName = "samplepdf.pdf" + val context = InstrumentationRegistry.getInstrumentation().context + val pdfBytes = context.assets.open(pdfFileName).use { it.readBytes() } + + Log.d(PREPARATION_TAG, "Upload PDF file to course root folder using teacher token.") + val uploadedFile = FileUploadsApi.uploadFile(courseId = courseRootFolder.id, assignmentId = null, file = pdfBytes, fileName = pdfFileName, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) + + Log.d(PREPARATION_TAG, "Seed a discussion topic with checkpoints and PDF attachment for '${course.name}' course.") + val discussionWithCheckpointsTitle = "Discussion with PDF Attachment" + val assignmentName = "Assignment with Checkpoints and PDF" + val replyToTopicDueDate = "2029-11-12T22:59:00Z" + val replyToEntryDueDate = "2029-11-19T22:59:00Z" + DiscussionTopicsApi.createDiscussionTopicWithCheckpoints(courseId = course.id, token = teacher.token, discussionTitle = discussionWithCheckpointsTitle, assignmentName = assignmentName, replyToTopicDueDate = replyToTopicDueDate, replyToEntryDueDate = replyToEntryDueDate, fileId = uploadedFile.id.toString()) + + val convertedReplyToTopicDueDate = "Due " + convertIso8601ToCanvasFormat("2029-11-12T22:59:00Z") + " 2:59 PM" + val convertedReplyToEntryDueDate = "Due " + convertIso8601ToCanvasFormat("2029-11-19T22:59:00Z") + " 2:59 PM" + Log.d(STEP_TAG, "Login with user: '${parent.name}', login id: '${parent.loginId}'.") + tokenLogin(parent) + + Log.d(ASSERTION_TAG, "Assert that the Dashboard Page is the landing page and it is loaded successfully.") + dashboardPage.waitForRender() + dashboardPage.assertPageObjects() + + Log.d(STEP_TAG, "Open the student selector and select '${student.shortName}'.") + dashboardPage.openStudentSelector() + dashboardPage.selectStudent(student.shortName) + + Log.d(STEP_TAG, "Click on the '${course.name}' course.") + coursesPage.clickCourseItem(course.name) + + Log.d(ASSERTION_TAG, "Assert that the details of the course has opened.") + courseDetailsPage.assertCourseNameDisplayed(course) + + Log.d(ASSERTION_TAG, "Assert that the '${discussionWithCheckpointsTitle}' discussion is present along with 2 date info (For the 2 checkpoints).") + courseDetailsPage.assertHasAssignmentWithCheckpoints(discussionWithCheckpointsTitle, dueAtString = convertedReplyToTopicDueDate, dueAtStringSecondCheckpoint = convertedReplyToEntryDueDate, expectedGrade = "-/15") + + Log.d(STEP_TAG, "Click on '$discussionWithCheckpointsTitle' assignment to open its details.") + courseDetailsPage.clickAssignment(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that Assignment Details Page is displayed with correct title.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertAssignmentTitle(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that attachment icon is displayed.") + assignmentDetailsPage.assertAttachmentIconDisplayed() + + Log.d(STEP_TAG, "Click on attachment icon to download it.") + assignmentDetailsPage.clickAttachmentIcon() + + Log.d(STEP_TAG, "Wait for download to complete.") + sleep(5000) + + Log.d(STEP_TAG, "Open the Notification bar.") + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.openNotification() + + retryWithIncreasingDelay(times = 10, maxDelay = 3000) { + Log.d(STEP_TAG, "Find download notification.") + val downloadNotification = device.findObject(UiSelector().textContains(pdfFileName).className("android.widget.TextView")) + + Log.d(ASSERTION_TAG, "Assert that 'Download complete' text is displayed in notification.") + val downloadCompleteText = device.findObject(UiSelector().textContains("Download complete")) + assert(downloadCompleteText.exists()) { "Download complete text not found in notification" } + + Log.d(ASSERTION_TAG, "Assert that file name '$pdfFileName' is displayed in notification.") + assert(downloadNotification.exists()) { "File name '$pdfFileName' not found in notification" } + } + + Log.d(STEP_TAG, "Close notification shade.") + device.pressBack() + + Log.d(STEP_TAG, "Navigate back from the Assignment details page.") + Espresso.pressBack() + + Log.d(ASSERTION_TAG, "Assert that we are back to the course details page.") + courseDetailsPage.assertCourseNameDisplayed(course) + } + +} \ No newline at end of file diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/InboxE2ETest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/InboxE2ETest.kt index d4cbd79e91..a3861bc3b4 100644 --- a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/InboxE2ETest.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/InboxE2ETest.kt @@ -30,6 +30,7 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E +import com.instructure.canvas.espresso.annotations.ReleaseExclude import com.instructure.canvas.espresso.refresh import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.toApiString @@ -59,6 +60,7 @@ class InboxE2ETest: ParentComposeTest() { @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) + @ReleaseExclude fun testInboxSelectedButtonActionsE2E() { Log.d(PREPARATION_TAG, "Seeding data.") diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/LoginE2ETest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/LoginE2ETest.kt index 09c1be3df3..9c7bfb61fe 100644 --- a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/LoginE2ETest.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/LoginE2ETest.kt @@ -16,8 +16,11 @@ */ package com.instructure.parentapp.ui.e2e.compose +import android.app.Instrumentation import android.util.Log import androidx.test.espresso.Espresso +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory @@ -240,13 +243,15 @@ class LoginE2ETest : ParentComposeTest() { @E2E @Test + @Stub("MBL-19866") @TestMetaData(Priority.IMPORTANT, FeatureCategory.LOGIN, TestCategory.E2E) fun testInvalidAndEmptyLoginCredentialsE2E() { val INVALID_USERNAME = "invalidusercred@test.com" val INVALID_PASSWORD = "invalidpw" - val INVALID_CREDENTIALS_ERROR_MESSAGE = "Please verify your username or password and try again. Trouble logging in? Check out our Login FAQs." - val NO_PASSWORD_GIVEN_ERROR_MESSAGE = "No password was given" + val INVALID_CREDENTIALS_ERROR_MESSAGE = "Please verify your username or password and try again." + val NO_EMAIL_GIVEN_ERROR_MESSAGE = "Please enter your email." + val NO_PASSWORD_GIVEN_ERROR_MESSAGE = "Please enter your password." val DOMAIN = "mobileqa.beta" Log.d(STEP_TAG, "Click 'Find My School' button.") @@ -258,29 +263,31 @@ class LoginE2ETest : ParentComposeTest() { Log.d(STEP_TAG, "Click on 'Next' button on the Toolbar.") loginFindSchoolPage.clickToolbarNextMenuItem() + /* Somehow React does not recognize the invalid credentials, need to be fixed in follow-up ticket Log.d(STEP_TAG, "Try to login with invalid, non-existing credentials ('$INVALID_USERNAME', '$INVALID_PASSWORD').") loginSignInPage.loginAs(INVALID_USERNAME, INVALID_PASSWORD) Log.d(ASSERTION_TAG, "Assert that the invalid credentials error message is displayed.") - loginSignInPage.assertLoginErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) - + loginSignInPage.assertLoginEmailErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) // Invalid credentials error message will be displayed within the email error message holder on the login page. + */ Log.d(STEP_TAG, "Try to login with no credentials typed in either of the username and password field.") loginSignInPage.loginAs(EMPTY_STRING, EMPTY_STRING) - Log.d(ASSERTION_TAG, "Assert that the no password was given error message is displayed.") - loginSignInPage.assertLoginErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) + Log.d(ASSERTION_TAG, "Assert that the no email and no password error messages are displayed.") + loginSignInPage.assertLoginEmailErrorMessage(NO_EMAIL_GIVEN_ERROR_MESSAGE) + loginSignInPage.assertLoginPasswordErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) Log.d(STEP_TAG, "Try to login with leaving only the password field empty.") loginSignInPage.loginAs(INVALID_USERNAME, EMPTY_STRING) Log.d(ASSERTION_TAG, "Assert that the no password was given error message is displayed.") - loginSignInPage.assertLoginErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) + loginSignInPage.assertLoginEmailErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) Log.d(STEP_TAG, "Try to login with leaving only the username field empty.") loginSignInPage.loginAs(EMPTY_STRING, INVALID_PASSWORD) - Log.d(ASSERTION_TAG, "Assert that the invalid credentials error message is displayed.") - loginSignInPage.assertLoginErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) + Log.d(ASSERTION_TAG, "Assert that the no email error message is displayed.") + loginSignInPage.assertLoginEmailErrorMessage(NO_EMAIL_GIVEN_ERROR_MESSAGE) // Invalid credentials error message will be displayed within the email error message holder on the login page. } private fun loginWithUser(user: CanvasUserApiModel, lastSchoolSaved: Boolean = false) { @@ -449,4 +456,34 @@ class LoginE2ETest : ParentComposeTest() { loginSignInPage.assertPageObjects() } + @E2E + @Test + @TestMetaData(Priority.NICE_TO_HAVE, FeatureCategory.LOGIN, TestCategory.E2E) + fun testLoginHowDoIFindMySchoolE2E() { + + Log.d(STEP_TAG, "Click 'Find My School' button.") + loginLandingPage.clickFindMySchoolButton() + + Log.d(STEP_TAG, "Enter and invalid domain to trigger the 'Tap here for login help.' link to be displayed.") + loginFindSchoolPage.enterDomain("invalid-domain") + + Log.d(ASSERTION_TAG, "Assert that the 'Tap here for login help.' link is displayed.") + loginFindSchoolPage.assertHowDoIFindMySchoolLinkDisplayed() + + val expectedUrl = "https://community.instructure.com/en/kb/articles/662717-where-do-i-find-my-institutions-url-to-access-canvas" + val expectedIntent = IntentMatchers.hasData(expectedUrl) + Intents.init() + try { + Intents.intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null)) + + Log.d(STEP_TAG, "Click on the 'Tap here for login help.' link.") + loginFindSchoolPage.clickOnHowDoIFindMySchoolLink() + + Log.d(ASSERTION_TAG, "Assert that an intent with the correct URL was fired.") + Intents.intended(expectedIntent) + } finally { + Intents.release() + } + } + } \ No newline at end of file diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/compose/CourseDetailsPage.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/compose/CourseDetailsPage.kt index 6fb621b08a..31d32d4e8e 100644 --- a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/compose/CourseDetailsPage.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/compose/CourseDetailsPage.kt @@ -18,19 +18,26 @@ package com.instructure.parentapp.ui.pages.compose import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyChild +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasParent import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import com.instructure.canvas.espresso.refresh import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.utils.toDate import com.instructure.dataseeding.model.CourseApiModel import com.instructure.espresso.assertTextColor +import com.instructure.espresso.retryWithIncreasingDelay +import com.instructure.pandautils.utils.toFormattedString class CourseDetailsPage(private val composeTestRule: ComposeTestRule) { @@ -76,4 +83,97 @@ class CourseDetailsPage(private val composeTestRule: ComposeTestRule) { fun clickComposeMessageFAB() { composeTestRule.onNodeWithContentDescription("Send a message about this course").performClick() } + + fun assertHasAssignmentWithCheckpoints(assignmentName: String, dueAtString: String = "No due date", dueAtStringSecondCheckpoint: String? = null, expectedGrade: String? = null) { + assertHasAssignmentCommon(assignmentName, dueAtString, dueAtStringSecondCheckpoint, expectedGrade, hasCheckPoints = true) + } + + private fun assertHasAssignmentCommon(assignmentName: String, assignmentDueAt: String?, secondCheckpointDueAt: String? = null, expectedGradeLabel: String? = null, assignmentStatus: String? = null, hasCheckPoints : Boolean = false) { + + // Check if the assignment is a discussion with checkpoints, if yes, we are expecting 2 due dates for the 2 checkpoints. + if(hasCheckPoints) { + if (assignmentDueAt == null || assignmentDueAt == "No due date") { + composeTestRule.onAllNodes( + hasText("No due date").and( + hasParent(hasAnyDescendant(hasText(assignmentName))) + ), + true + ).assertCountEquals(2) + } + else { + if(secondCheckpointDueAt != null) { + composeTestRule.onAllNodes( + hasText(assignmentDueAt).and( + hasParent(hasAnyDescendant(hasText(assignmentName))) + ), + true + ).assertCountEquals(1) + composeTestRule.onAllNodes( + hasText(secondCheckpointDueAt).and( + hasParent(hasAnyDescendant(hasText(assignmentName))) + ), + true + ).assertCountEquals(1) + } + else { + composeTestRule.onAllNodes( + hasText(assignmentDueAt).and( + hasParent(hasAnyDescendant(hasText(assignmentName))) + ), + true + ).assertCountEquals(2) + } + } + } + else { + // Check that either the assignment due date is present, or "No Due Date" is displayed + if (assignmentDueAt != null) { + composeTestRule.onNode( + hasText(assignmentName).and( + hasParent( + hasAnyDescendant( + hasText( + "Due ${ + assignmentDueAt.toDate()!!.toFormattedString() + }" + ) + ) + ) + ) + ) + .assertIsDisplayed() + } else { + composeTestRule.onNode( + hasText(assignmentName).and( + hasParent(hasAnyDescendant(hasText("No due date"))) + ) + ) + .assertIsDisplayed() + } + } + + retryWithIncreasingDelay(times = 10, maxDelay = 4000, catchBlock = { refresh() }) { + // Check that grade is present, if that is specified + if (expectedGradeLabel != null) { + composeTestRule.onNode( + hasText(assignmentName).and( + hasParent(hasAnyDescendant(hasText(expectedGradeLabel, substring = true))) + ) + ) + .assertIsDisplayed() + } + } + + retryWithIncreasingDelay(times = 10, maxDelay = 4000, catchBlock = { refresh() }) { + if(assignmentStatus != null) { + composeTestRule.onNode( + hasText(assignmentStatus).and( + hasAnyAncestor(hasAnyChild(hasText(assignmentName))) + ), + useUnmergedTree = true + ) + .assertIsDisplayed() + } + } + } } diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/compose/ParentAssignmentDetailsPage.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/compose/ParentAssignmentDetailsPage.kt new file mode 100644 index 0000000000..5183ae565b --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/compose/ParentAssignmentDetailsPage.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 - present Instructure, 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. + */ +package com.instructure.parentapp.ui.pages.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import com.instructure.canvas.espresso.common.pages.AssignmentDetailsPage +import com.instructure.espresso.ModuleItemInteractions +import com.instructure.espresso.page.withId +import com.instructure.parentapp.R + +class ParentAssignmentDetailsPage(moduleItemInteractions: ModuleItemInteractions, composeTestRule: ComposeTestRule): AssignmentDetailsPage(moduleItemInteractions, composeTestRule) { + + fun assertDiscussionCheckpointDetailsOnDetailsPage(checkpointText: String, dueAt: String) + { + composeTestRule.waitForIdle() + try { + composeTestRule.onNode(hasText(dueAt) and hasAnyAncestor(hasTestTag("dueDateColumn-$checkpointText") and hasAnyDescendant(hasTestTag("dueDateHeaderText-$checkpointText")))).assertIsDisplayed() + } catch (e: AssertionError) { + Espresso.onView(withId(R.id.dueComposeView)).perform(ViewActions.scrollTo()) + composeTestRule.waitForIdle() + } + + composeTestRule.onNode(hasTestTag("dueDateHeaderText-$checkpointText"), useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNode(hasText(dueAt) and hasAnyAncestor(hasTestTag("dueDateColumn-$checkpointText") and hasAnyDescendant(hasTestTag("dueDateHeaderText-$checkpointText")))).assertIsDisplayed() + } + +} \ No newline at end of file diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentComposeTest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentComposeTest.kt index 81beeda7f9..decbb367bf 100644 --- a/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentComposeTest.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentComposeTest.kt @@ -18,7 +18,6 @@ package com.instructure.parentapp.utils import androidx.compose.ui.test.junit4.createAndroidComposeRule -import com.instructure.canvas.espresso.common.pages.AssignmentDetailsPage import com.instructure.canvas.espresso.common.pages.AssignmentReminderPage import com.instructure.canvas.espresso.common.pages.compose.CalendarEventCreateEditPage import com.instructure.canvas.espresso.common.pages.compose.CalendarEventDetailsPage @@ -43,6 +42,7 @@ import com.instructure.parentapp.ui.pages.compose.CreateAccountPage import com.instructure.parentapp.ui.pages.compose.ManageStudentsPage import com.instructure.parentapp.ui.pages.compose.NotAParentPage import com.instructure.parentapp.ui.pages.compose.PairingCodePage +import com.instructure.parentapp.ui.pages.compose.ParentAssignmentDetailsPage import com.instructure.parentapp.ui.pages.compose.ParentInboxCoursePickerPage import com.instructure.parentapp.ui.pages.compose.QrPairingPage import com.instructure.parentapp.ui.pages.compose.StudentAlertSettingsPage @@ -83,7 +83,7 @@ abstract class ParentComposeTest : ParentTest() { protected val calendarFilterPage = CalendarFilterPage(composeTestRule) protected val assignmentReminderPage = AssignmentReminderPage(composeTestRule) protected val inboxSignatureSettingsPage = InboxSignatureSettingsPage(composeTestRule) - protected val assignmentDetailsPage = AssignmentDetailsPage(ModuleItemInteractions(), composeTestRule) + protected val assignmentDetailsPage = ParentAssignmentDetailsPage(ModuleItemInteractions(), composeTestRule) override fun displaysPageObjects() = Unit } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CourseDetailsModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CourseDetailsModule.kt index 5ce103c209..9e9391cb76 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CourseDetailsModule.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CourseDetailsModule.kt @@ -18,6 +18,7 @@ package com.instructure.parentapp.di.feature import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.PageAPI import com.instructure.canvasapi2.apis.TabAPI import com.instructure.parentapp.features.courses.details.CourseDetailsRepository import dagger.Module @@ -33,8 +34,9 @@ class CourseDetailsModule { @Provides fun provideCourseDetailsRepository( courseApi: CourseAPI.CoursesInterface, - tabsInterface: TabAPI.TabsInterface + tabsInterface: TabAPI.TabsInterface, + pageApi: PageAPI.PagesInterface ): CourseDetailsRepository { - return CourseDetailsRepository(courseApi, tabsInterface) + return CourseDetailsRepository(courseApi, tabsInterface, pageApi) } } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/addstudent/qr/QrPairingScreen.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/addstudent/qr/QrPairingScreen.kt index e8029a7796..a2214172a7 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/addstudent/qr/QrPairingScreen.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/addstudent/qr/QrPairingScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material.ButtonDefaults import androidx.compose.material.OutlinedButton -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable @@ -43,6 +42,7 @@ import androidx.compose.ui.unit.sp import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.compose.CanvasTheme import com.instructure.pandautils.compose.composables.CanvasAppBar +import com.instructure.pandautils.compose.composables.CanvasScaffold import com.instructure.pandautils.compose.composables.Loading import com.instructure.parentapp.R import com.instructure.parentapp.features.addstudent.AddStudentAction @@ -55,7 +55,7 @@ fun QrPairingScreen( onBackClicked: () -> Unit ) { CanvasTheme { - Scaffold( + CanvasScaffold( backgroundColor = colorResource(id = R.color.backgroundLightest), topBar = { CanvasAppBar( diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/alerts/details/AnnouncementDetailsScreen.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/alerts/details/AnnouncementDetailsScreen.kt index 3df92ae074..c850163513 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/alerts/details/AnnouncementDetailsScreen.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/alerts/details/AnnouncementDetailsScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Scaffold import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState import androidx.compose.material.SnackbarResult @@ -55,6 +54,7 @@ import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.DateHelper import com.instructure.pandares.R import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.compose.composables.CanvasScaffold import com.instructure.pandautils.compose.composables.CanvasThemedAppBar import com.instructure.pandautils.compose.composables.ComposeCanvasWebViewWrapper import com.instructure.pandautils.compose.composables.ErrorContent @@ -86,7 +86,7 @@ fun AnnouncementDetailsScreen( } } - Scaffold( + CanvasScaffold( backgroundColor = colorResource(id = R.color.backgroundLightest), topBar = { CanvasThemedAppBar( diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/alerts/settings/AlertSettingsScreen.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/alerts/settings/AlertSettingsScreen.kt index 66af9ed9e6..af4b88550b 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/alerts/settings/AlertSettingsScreen.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/alerts/settings/AlertSettingsScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material.AlertDialog import androidx.compose.material.ButtonDefaults import androidx.compose.material.ContentAlpha import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.TextField @@ -77,8 +76,9 @@ import com.instructure.canvasapi2.models.ThresholdWorkflowState import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.compose.CanvasTheme -import com.instructure.pandautils.compose.composables.CanvasAppBar +import com.instructure.pandautils.compose.composables.CanvasScaffold import com.instructure.pandautils.compose.composables.CanvasSwitch +import com.instructure.pandautils.compose.composables.CanvasThemedAppBar import com.instructure.pandautils.compose.composables.ErrorContent import com.instructure.pandautils.compose.composables.Loading import com.instructure.pandautils.compose.composables.OverflowMenu @@ -100,16 +100,16 @@ fun AlertSettingsScreen( navigationActionClick: () -> Unit ) { CanvasTheme { - Scaffold( + CanvasScaffold( backgroundColor = colorResource(id = R.color.backgroundLightest), topBar = { - CanvasAppBar( + CanvasThemedAppBar( title = stringResource(id = R.string.alertSettingsTitle), navIconRes = R.drawable.ic_back_arrow, navIconContentDescription = stringResource(id = R.string.back), navigationActionClick = navigationActionClick, backgroundColor = Color(uiState.userColor), - textColor = colorResource(id = R.color.textLightest), + contentColor = colorResource(id = R.color.textLightest), actions = { var showMenu by rememberSaveable { mutableStateOf(false) } var showConfirmationDialog by rememberSaveable { mutableStateOf(false) } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/assignment/details/ParentAssignmentDetailsBehaviour.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/assignment/details/ParentAssignmentDetailsBehaviour.kt index 3372d7d9d8..d9727a78c6 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/assignment/details/ParentAssignmentDetailsBehaviour.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/assignment/details/ParentAssignmentDetailsBehaviour.kt @@ -35,6 +35,7 @@ import com.instructure.pandautils.features.assignments.details.AssignmentDetails import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions import com.instructure.pandautils.utils.DP import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarMargin import com.instructure.pandautils.utils.onClick import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.studentColor @@ -96,6 +97,7 @@ class ParentAssignmentDetailsBehaviour @Inject constructor( marginEnd = context.DP(16).toInt() bottomMargin = context.DP(16).toInt() } + applyBottomSystemBarMargin() onClick { routeToCompose?.invoke(getInboxComposeOptions(context, course, assignment)) } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsRepository.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsRepository.kt index 16618fc9cc..ee5accb7eb 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsRepository.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsRepository.kt @@ -18,16 +18,19 @@ package com.instructure.parentapp.features.courses.details import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.PageAPI import com.instructure.canvasapi2.apis.TabAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Page import com.instructure.canvasapi2.models.Tab class CourseDetailsRepository( private val courseApi: CourseAPI.CoursesInterface, - private val tabApi: TabAPI.TabsInterface + private val tabApi: TabAPI.TabsInterface, + private val pageApi: PageAPI.PagesInterface ) { suspend fun getCourse(id: Long, forceRefresh: Boolean): Course { @@ -39,4 +42,9 @@ class CourseDetailsRepository( val params = RestParams(isForceReadFromNetwork = forceRefresh) return tabApi.getTabs(id, CanvasContext.Type.COURSE.apiString, params).dataOrThrow } + + suspend fun getFrontPage(courseId: Long, forceRefresh: Boolean): Page? { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return pageApi.getFrontPage(CanvasContext.Type.COURSE.apiString, courseId, params).dataOrNull + } } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsScreen.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsScreen.kt index f4f4196014..ef98d80518 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsScreen.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.FloatingActionButton import androidx.compose.material.Icon -import androidx.compose.material.Scaffold import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState import androidx.compose.material.SnackbarResult @@ -51,6 +50,7 @@ import androidx.compose.ui.unit.dp import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.compose.composables.CanvasScaffold import com.instructure.pandautils.compose.composables.CanvasThemedAppBar import com.instructure.pandautils.compose.composables.ErrorContent import com.instructure.pandautils.compose.composables.Loading @@ -197,7 +197,7 @@ private fun CourseDetailsScreenContent( } } - Scaffold( + CanvasScaffold( backgroundColor = colorResource(id = R.color.backgroundLightest), snackbarHost = { SnackbarHost( @@ -261,7 +261,7 @@ private fun CourseDetailsScreenContent( backgroundColor = Color(uiState.studentColor), onClick = { actionHandler(CourseDetailsAction.SendAMessage) - } + }, ) { Icon( painter = painterResource(id = R.drawable.ic_chat), diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsViewModel.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsViewModel.kt index abaa64438e..e5db26af35 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsViewModel.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/CourseDetailsViewModel.kt @@ -79,18 +79,18 @@ class CourseDetailsViewModel @Inject constructor( val course = repository.getCourse(courseId, forceRefresh) val tabs = repository.getCourseTabs(courseId, forceRefresh) + val frontPage = repository.getFrontPage(courseId, forceRefresh) - val hasHomePageAsFrontPage = course.homePage == Course.HomePage.HOME_WIKI + val showFrontPageTab = !frontPage?.body.isNullOrEmpty() val showSyllabusTab = !course.syllabusBody.isNullOrEmpty() && - (course.homePage == Course.HomePage.HOME_SYLLABUS || - (!hasHomePageAsFrontPage && tabs.any { it.tabId == Tab.SYLLABUS_ID })) + (course.homePage == Course.HomePage.HOME_SYLLABUS || tabs.any { it.tabId == Tab.SYLLABUS_ID }) val showSummary = showSyllabusTab && course.settings?.courseSummary.orDefault() val tabTypes = buildList { add(TabType.GRADES) - if (hasHomePageAsFrontPage) add(TabType.FRONT_PAGE) + if (showFrontPageTab) add(TabType.FRONT_PAGE) if (showSyllabusTab) add(TabType.SYLLABUS) if (showSummary) add(TabType.SUMMARY) } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/grades/ParentGradesScreen.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/grades/ParentGradesScreen.kt index e0686eb85f..68a7d6f97b 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/grades/ParentGradesScreen.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/details/grades/ParentGradesScreen.kt @@ -47,5 +47,5 @@ internal fun ParentGradesScreen( } } - GradesScreen(gradesUiState, gradesViewModel::handleAction, ParentPrefs.currentStudent.studentColor) + GradesScreen(gradesUiState, gradesViewModel::handleAction, ParentPrefs.currentStudent.studentColor, applyInsets = false) } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/dashboard/DashboardFragment.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/dashboard/DashboardFragment.kt index 00b5948c8b..90530c3809 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/dashboard/DashboardFragment.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/dashboard/DashboardFragment.kt @@ -18,6 +18,7 @@ package com.instructure.parentapp.features.dashboard import android.content.res.ColorStateList +import android.content.res.Configuration import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.Gravity @@ -29,6 +30,8 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.view.GravityCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -56,11 +59,13 @@ import com.instructure.pandautils.features.calendar.SharedCalendarAction import com.instructure.pandautils.features.help.HelpDialogFragment import com.instructure.pandautils.features.reminder.AlarmScheduler import com.instructure.pandautils.interfaces.NavigationCallbacks +import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.animateCircularBackgroundColorChange import com.instructure.pandautils.utils.announceAccessibilityText import com.instructure.pandautils.utils.applyTheme +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.collectDistinctUntilChanged import com.instructure.pandautils.utils.collectOneOffEvents import com.instructure.pandautils.utils.getDrawableCompat @@ -218,6 +223,34 @@ class DashboardFragment : BaseCanvasFragment(), NavigationCallbacks { navController.removeOnDestinationChangedListener(onDestinationChangedListener) } + override fun onResume() { + super.onResume() + updateStatusBarAppearanceForDrawer() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + updateStatusBarAppearanceForDrawer() + } + + private fun updateStatusBarAppearanceForDrawer() { + // Check if drawer is open and update status bar appearance accordingly (handles config changes) + // Post to ensure drawer state is checked after current layout pass + binding.drawerLayout.post { + if (binding.drawerLayout.isDrawerOpen(GravityCompat.START) && !ColorKeeper.darkTheme) { + activity?.window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = true + } + } else if (!ColorKeeper.darkTheme) { + activity?.window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = false + } + } + } + } + private fun handleAction(action: DashboardViewModelAction) { when (action) { is DashboardViewModelAction.AddStudent -> { @@ -275,6 +308,7 @@ class DashboardFragment : BaseCanvasFragment(), NavigationCallbacks { } private fun setupToolbar() { + binding.toolbar.applyTopSystemBarInsets() binding.navigationButtonHolder.contentDescription = getString(R.string.navigation_drawer_open) binding.navigationButtonHolder.onClick { openNavigationDrawer() @@ -287,6 +321,32 @@ class DashboardFragment : BaseCanvasFragment(), NavigationCallbacks { private fun setupNavigationDrawer() { val navView = binding.navView + ViewCompat.setOnApplyWindowInsetsListener(navView) { view, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + // In landscape, navigation buttons are on the sides - remove left/right padding + view.setPadding( + 0, // Remove left padding in landscape + insets.top, + 0, // Remove right padding in landscape + 0 + ) + } else { + // In portrait, apply normal insets + view.setPadding( + insets.left, + insets.top, + insets.right, + insets.bottom + ) + } + + windowInsets + } headerLayoutBinding = NavigationDrawerHeaderLayoutBinding.bind(navView.getHeaderView(0)) @@ -333,6 +393,24 @@ class DashboardFragment : BaseCanvasFragment(), NavigationCallbacks { override fun onDrawerOpened(drawerView: View) { closeNavigationDrawerItem.isVisible = isAccessibilityEnabled(requireContext()) super.onDrawerOpened(drawerView) + // Set status bar icons to dark only in light mode (for visibility on white drawer background) + if (!ColorKeeper.darkTheme) { + activity?.window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = true + } + } + } + + override fun onDrawerClosed(drawerView: View) { + super.onDrawerClosed(drawerView) + // Restore status bar icons to light only in light mode (for dark toolbar) + if (!ColorKeeper.darkTheme) { + activity?.window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = false + } + } } }) } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/list/ParentInboxRouter.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/list/ParentInboxRouter.kt index ec3d946646..6e58aa5199 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/list/ParentInboxRouter.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/inbox/list/ParentInboxRouter.kt @@ -37,7 +37,7 @@ class ParentInboxRouter( ) : InboxRouter { override fun openConversation(conversation: Conversation, scope: InboxApi.Scope) { - navigation.navigate(activity, navigation.inboxDetailsRoute(conversation.id, conversation.workflowState == Conversation.WorkflowState.UNREAD)) + navigation.navigate(activity, navigation.inboxDetailsRoute(conversation.id, conversation.workflowState == Conversation.WorkflowState.UNREAD, scope)) } override fun attachNavigationIcon(toolbar: Toolbar) { diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/login/createaccount/CreateAccountActivity.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/login/createaccount/CreateAccountActivity.kt index 6c3e731621..502aa303bf 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/login/createaccount/CreateAccountActivity.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/login/createaccount/CreateAccountActivity.kt @@ -17,11 +17,14 @@ package com.instructure.parentapp.features.login.createaccount import android.os.Bundle +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import com.instructure.pandautils.base.BaseCanvasActivity import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.interfaces.NavigationCallbacks +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.parentapp.R import com.instructure.parentapp.databinding.ActivityCreateAccountBinding import com.instructure.parentapp.util.navigation.Navigation @@ -40,10 +43,32 @@ class CreateAccountActivity : BaseCanvasActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + EdgeToEdgeHelper.enableEdgeToEdge(this) setContentView(binding.root) + setupWindowInsets() setupNavigation() } + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + // Apply both navigation bar and display cutout insets + // This ensures content is not hidden behind the navigation bar OR the hole punch camera + val leftPadding = maxOf(navigationBars.left, displayCutout.left) + val rightPadding = maxOf(navigationBars.right, displayCutout.right) + + view.setPadding( + leftPadding, + 0, + rightPadding, + 0 + ) + insets + } + } + private fun setupNavigation() { val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/login/createaccount/CreateAccountScreen.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/login/createaccount/CreateAccountScreen.kt index 9c61d69476..98aaa97996 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/login/createaccount/CreateAccountScreen.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/login/createaccount/CreateAccountScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState import androidx.compose.material.SnackbarResult @@ -77,6 +76,7 @@ import androidx.compose.ui.unit.sp import com.instructure.canvasapi2.models.TermsOfService import com.instructure.pandares.R import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.compose.composables.CanvasScaffold import com.instructure.pandautils.compose.composables.Loading @Composable @@ -96,7 +96,7 @@ internal fun CreateAccountScreen( } } - Scaffold( + CanvasScaffold( backgroundColor = colorResource(id = R.color.backgroundLightest), snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, content = { padding -> diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainActivity.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainActivity.kt index 02550a9980..d20d94e442 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainActivity.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainActivity.kt @@ -27,6 +27,9 @@ import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment @@ -44,6 +47,7 @@ import com.instructure.pandautils.interfaces.NavigationCallbacks import com.instructure.pandautils.utils.AppType import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.WebViewAuthenticator import com.instructure.pandautils.utils.toast @@ -83,7 +87,9 @@ class MainActivity : BaseCanvasActivity(), OnUnreadCountInvalidated, Masqueradin override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + EdgeToEdgeHelper.enableEdgeToEdge(this) setContentView(binding.root) + setupWindowInsets() setupTheme() setupNavigation() handleQrMasquerading() @@ -93,6 +99,47 @@ class MainActivity : BaseCanvasActivity(), OnUnreadCountInvalidated, Masqueradin RatingDialog.showRatingDialog(this, AppType.PARENT) } + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + // Apply both navigation bar and display cutout insets + // This ensures content is not hidden behind the navigation bar OR the hole punch camera + val leftPadding = maxOf(navigationBars.left, displayCutout.left) + val rightPadding = maxOf(navigationBars.right, displayCutout.right) + + view.setPadding( + leftPadding, + 0, + rightPadding, + 0 + ) + + // Consume horizontal insets so child ComposeViews don't apply them again + WindowInsetsCompat.Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + Insets.of( + 0, // Consume left + navigationBars.top, + 0, // Consume right + navigationBars.bottom + ) + ) + .setInsets( + WindowInsetsCompat.Type.displayCutout(), + Insets.of( + 0, // Consume left + displayCutout.top, + 0, // Consume right + displayCutout.bottom + ) + ) + .build() + } + } + override fun onResume() { super.onResume() webViewAuthenticator.authenticateWebViews(lifecycleScope, this) diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/managestudents/ManageStudentsScreen.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/managestudents/ManageStudentsScreen.kt index 2e43b4627a..21c7acd5c8 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/managestudents/ManageStudentsScreen.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/managestudents/ManageStudentsScreen.kt @@ -21,6 +21,7 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -36,7 +37,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.FloatingActionButton import androidx.compose.material.Icon -import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh @@ -67,6 +67,7 @@ import androidx.compose.ui.unit.sp import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.compose.composables.CanvasScaffold import com.instructure.pandautils.compose.composables.CanvasThemedAppBar import com.instructure.pandautils.compose.composables.EmptyContent import com.instructure.pandautils.compose.composables.ErrorContent @@ -82,7 +83,7 @@ internal fun ManageStudentsScreen( modifier: Modifier = Modifier ) { CanvasTheme { - Scaffold( + CanvasScaffold( backgroundColor = colorResource(id = R.color.backgroundLightest), topBar = { CanvasThemedAppBar( @@ -117,9 +118,8 @@ internal fun ManageStudentsScreen( StudentListContent( uiState = uiState, actionHandler = actionHandler, - modifier = Modifier - .padding(padding) - .fillMaxSize() + scaffoldPadding = padding, + modifier = Modifier.fillMaxSize() ) } }, @@ -148,7 +148,8 @@ internal fun ManageStudentsScreen( private fun StudentListContent( uiState: ManageStudentsUiState, actionHandler: (ManageStudentsAction) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + scaffoldPadding: PaddingValues = PaddingValues(0.dp) ) { val pullRefreshState = rememberPullRefreshState( refreshing = uiState.isLoading, @@ -177,7 +178,8 @@ private fun StudentListContent( modifier = modifier.pullRefresh(pullRefreshState) ) { LazyColumn( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + contentPadding = scaffoldPadding ) { items(uiState.studentListItems) { StudentListItem(it, actionHandler) diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/notaparent/NotAParentScreen.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/notaparent/NotAParentScreen.kt index d21303e7a3..54354eb5a5 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/notaparent/NotAParentScreen.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/notaparent/NotAParentScreen.kt @@ -17,6 +17,7 @@ package com.instructure.parentapp.features.notaparent +import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.animation.animateContentSize import androidx.compose.foundation.clickable @@ -24,8 +25,11 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize 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 @@ -43,6 +47,7 @@ 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.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource @@ -64,6 +69,10 @@ internal fun NotAParentScreen( onTeacherClick: () -> Unit, modifier: Modifier = Modifier ) { + val configuration = LocalConfiguration.current + val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT + val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues() + Surface( color = colorResource(id = R.color.backgroundLightest), modifier = modifier @@ -75,7 +84,11 @@ internal fun NotAParentScreen( Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = if (isPortrait) navigationBarPadding.calculateBottomPadding() else 0.dp + ) ) { Spacer(modifier = Modifier.weight(1f)) EmptyContent( diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/webview/HtmlContentActivity.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/webview/HtmlContentActivity.kt index 78109c437b..31ea0e0073 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/webview/HtmlContentActivity.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/webview/HtmlContentActivity.kt @@ -20,10 +20,14 @@ package com.instructure.parentapp.features.webview import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.instructure.pandautils.base.BaseCanvasActivity import com.instructure.pandautils.fragments.HtmlContentFragment import com.instructure.pandautils.fragments.HtmlContentFragment.Companion.DARK_TOOLBAR import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.parentapp.R import dagger.hilt.android.AndroidEntryPoint @@ -33,8 +37,11 @@ class HtmlContentActivity : BaseCanvasActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + EdgeToEdgeHelper.enableEdgeToEdge(this) setContentView(R.layout.activity_html_content) + setupWindowInsets() + if (savedInstanceState == null) { val title = intent.getStringExtra(Const.TITLE).orEmpty() val html = intent.getStringExtra(Const.HTML).orEmpty() @@ -50,6 +57,20 @@ class HtmlContentActivity : BaseCanvasActivity() { } } + private fun setupWindowInsets() { + val root = findViewById(R.id.fragmentContainer) + ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.updatePadding( + left = insets.left, + right = insets.right + ) + windowInsets + } + } + companion object { fun createIntent(context: Context, title: String, html: String, darkToolbar: Boolean): Intent { return Intent(context, HtmlContentActivity::class.java).apply { diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/webview/SimpleWebViewFragment.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/webview/SimpleWebViewFragment.kt index c0835c923e..861a69e8f1 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/webview/SimpleWebViewFragment.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/webview/SimpleWebViewFragment.kt @@ -19,8 +19,6 @@ package com.instructure.parentapp.features.webview import android.content.res.ColorStateList import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -38,6 +36,7 @@ import com.instructure.pandautils.mvvm.ViewState import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.NullableStringArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.collectOneOffEvents import com.instructure.pandautils.utils.enableAlgorithmicDarkening import com.instructure.pandautils.utils.launchCustomTab @@ -69,6 +68,8 @@ class SimpleWebViewFragment : BaseCanvasFragment(), NavigationCallbacks { private var title: String? by NullableStringArg(key = Const.TITLE) + private var customTabLaunched = false + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_simple_webview, container, false) } @@ -87,13 +88,23 @@ class SimpleWebViewFragment : BaseCanvasFragment(), NavigationCallbacks { } savedInstanceState?.let { + customTabLaunched = it.getBoolean(KEY_CUSTOM_TAB_LAUNCHED, false) binding.webView.restoreState(it) binding.webView.enableAlgorithmicDarkening() } } + override fun onResume() { + super.onResume() + if (customTabLaunched) { + customTabLaunched = false + activity?.supportFragmentManager?.popBackStack() + } + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) + outState.putBoolean(KEY_CUSTOM_TAB_LAUNCHED, customTabLaunched) binding.webView.saveState(outState) } @@ -111,12 +122,16 @@ class SimpleWebViewFragment : BaseCanvasFragment(), NavigationCallbacks { is SimpleWebViewAction.ShowError -> { toast(com.instructure.pandautils.R.string.errorOccurred) - activity?.onBackPressed() + if (isAdded && !parentFragmentManager.isStateSaved) { + activity?.supportFragmentManager?.popBackStack() + } } } } private fun applyTheme() = with(binding) { + webView.setPadding(0, 0, 0, 0) + toolbar.applyTopSystemBarInsets() toolbar.title = title.orEmpty() toolbar.setupAsBackButton(this@SimpleWebViewFragment) ViewStyler.themeToolbarColored( @@ -159,18 +174,34 @@ class SimpleWebViewFragment : BaseCanvasFragment(), NavigationCallbacks { } } + webView.setMediaDownloadCallback(object : CanvasWebView.MediaDownloadCallback { + override fun downloadMedia(mime: String?, url: String?, filename: String?) { + if (!limitWebAccess) { + viewModel.downloadFile(mime.orEmpty(), url.orEmpty(), filename.orEmpty()) + } + } + + override fun downloadInternalMedia(mime: String?, url: String?, filename: String?) { + if (!limitWebAccess) { + viewModel.downloadFile(mime.orEmpty(), url.orEmpty(), filename.orEmpty()) + } + } + }) + webView.loadUrl(mainUrl) } private fun launchCustomTab(url: String) { activity?.let { + customTabLaunched = true it.launchCustomTab(url, parentPrefs.currentStudent.studentColor) - Handler(Looper.getMainLooper()).postDelayed({ - it.onBackPressed() - }, 500) } } + companion object { + private const val KEY_CUSTOM_TAB_LAUNCHED = "key_custom_tab_launched" + } + private fun showAlertJavascript(webView: WebView, infoText: String = getString(R.string.webAccessLimitedMessage)) { val showAlertJavaScrip = """ const floatNode = `
diff --git a/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt b/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt index 421e54877b..48429754f4 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/util/navigation/Navigation.kt @@ -21,6 +21,7 @@ import com.instructure.pandautils.features.calendarevent.details.EventFragment import com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdateToDoFragment import com.instructure.pandautils.features.calendartodo.details.ToDoFragment import com.instructure.pandautils.features.inbox.compose.InboxComposeFragment +import com.instructure.canvasapi2.apis.InboxApi import com.instructure.pandautils.features.inbox.details.InboxDetailsFragment import com.instructure.pandautils.features.inbox.list.InboxFragment import com.instructure.pandautils.features.inbox.utils.InboxComposeOptions @@ -56,7 +57,7 @@ class Navigation(apiPrefs: ApiPrefs) { private val globalAnnouncementDetails = "$baseUrl/account_notifications/{$announcementId}" private val assignmentDetails = "$baseUrl/courses/{${Const.COURSE_ID}}/assignments/{${Const.ASSIGNMENT_ID}}" private val inboxCompose = "$baseUrl/conversations/compose/{${InboxComposeOptions.COMPOSE_PARAMETERS}}" - private val inboxDetails = "$baseUrl/conversations/{${InboxDetailsFragment.CONVERSATION_ID}}?unread={${InboxDetailsFragment.UNREAD}}" + private val inboxDetails = "$baseUrl/conversations/{${InboxDetailsFragment.CONVERSATION_ID}}?unread={${InboxDetailsFragment.UNREAD}}&scope={${InboxDetailsFragment.SCOPE}}" private val calendarEvent = "$baseUrl/{${EventFragment.CONTEXT_TYPE}}/{${EventFragment.CONTEXT_ID}}/calendar_events/{${EventFragment.SCHEDULE_ITEM_ID}}" private val createEvent = "$baseUrl/create-event/{${CreateUpdateEventFragment.INITIAL_DATE}}" private val updateEvent = "$baseUrl/update-event/{${CreateUpdateEventFragment.SCHEDULE_ITEM}}" @@ -83,7 +84,7 @@ class Navigation(apiPrefs: ApiPrefs) { private fun splashRoute(qrCodeMasqueradeId: Long) = "$baseUrl/splash/$qrCodeMasqueradeId" fun assignmentDetailsRoute(courseId: Long, assignmentId: Long) = "$baseUrl/courses/${courseId}/assignments/${assignmentId}" fun inboxComposeRoute(options: InboxComposeOptions) = "$baseUrl/conversations/compose/${InboxComposeOptionsParametersType.serializeAsValue(options)}" - fun inboxDetailsRoute(conversationId: Long, unread: Boolean) = "$baseUrl/conversations/$conversationId?unread=$unread" + fun inboxDetailsRoute(conversationId: Long, unread: Boolean, scope: InboxApi.Scope = InboxApi.Scope.INBOX) = "$baseUrl/conversations/$conversationId?unread=$unread&scope=${scope.name}" fun createAccount(domain: String, accountId: String, pairingCode: String) = "$baseUrl/account_creation?pairing_code=$pairingCode&domain=$domain&accountId=$accountId" fun courseDetailsRoute(id: Long) = "$baseUrl/courses/$id" fun calendarEventRoute(contextTypeString: String, contextId: Long, eventId: Long) = "$baseUrl/$contextTypeString/$contextId/calendar_events/$eventId" @@ -140,6 +141,11 @@ class Navigation(apiPrefs: ApiPrefs) { nullable = false defaultValue = false } + argument(InboxDetailsFragment.SCOPE) { + type = NavType.StringType + nullable = true + defaultValue = null + } } fragment(manageStudents) fragment(qrPairing) diff --git a/apps/parent/src/main/res/layout/fragment_dashboard.xml b/apps/parent/src/main/res/layout/fragment_dashboard.xml index 340e036fd0..5286ae3761 100644 --- a/apps/parent/src/main/res/layout/fragment_dashboard.xml +++ b/apps/parent/src/main/res/layout/fragment_dashboard.xml @@ -34,6 +34,7 @@ android:layout_height="match_parent"> @@ -58,6 +59,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize" app:contentInsetStart="0dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> diff --git a/apps/parent/src/main/res/layout/fragment_simple_webview.xml b/apps/parent/src/main/res/layout/fragment_simple_webview.xml index 869b13aa97..1a3bceec93 100644 --- a/apps/parent/src/main/res/layout/fragment_simple_webview.xml +++ b/apps/parent/src/main/res/layout/fragment_simple_webview.xml @@ -24,7 +24,8 @@ @style/NoGifEditText @style/NoGifEditText @font/lato_font_family - true \ No newline at end of file diff --git a/apps/parent/src/test/java/com/instructure/parentapp/features/courses/details/CourseDetailsRepositoryTest.kt b/apps/parent/src/test/java/com/instructure/parentapp/features/courses/details/CourseDetailsRepositoryTest.kt index f81a8268ed..4b8c47cbf0 100644 --- a/apps/parent/src/test/java/com/instructure/parentapp/features/courses/details/CourseDetailsRepositoryTest.kt +++ b/apps/parent/src/test/java/com/instructure/parentapp/features/courses/details/CourseDetailsRepositoryTest.kt @@ -18,10 +18,12 @@ package com.instructure.parentapp.features.courses.details import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.PageAPI import com.instructure.canvasapi2.apis.TabAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Page import com.instructure.canvasapi2.models.Tab import com.instructure.canvasapi2.utils.DataResult import io.mockk.coEvery @@ -35,8 +37,9 @@ class CourseDetailsRepositoryTest { private val courseApi: CourseAPI.CoursesInterface = mockk(relaxed = true) private val tabApi: TabAPI.TabsInterface = mockk(relaxed = true) + private val pageApi: PageAPI.PagesInterface = mockk(relaxed = true) - private val repository = CourseDetailsRepository(courseApi, tabApi) + private val repository = CourseDetailsRepository(courseApi, tabApi, pageApi) @Test fun `Get course details successfully returns data`() = runTest { @@ -85,4 +88,34 @@ class CourseDetailsRepositoryTest { repository.getCourseTabs(1L, true) } + + @Test + fun `Get front page successfully returns page`() = runTest { + val expected = Page(body = "Front page content") + + coEvery { + pageApi.getFrontPage( + CanvasContext.Type.COURSE.apiString, + 1L, + RestParams(isForceReadFromNetwork = false) + ) + } returns DataResult.Success(expected) + + val result = repository.getFrontPage(1L, false) + Assert.assertEquals(expected, result) + } + + @Test + fun `Get front page returns null when fails`() = runTest { + coEvery { + pageApi.getFrontPage( + CanvasContext.Type.COURSE.apiString, + 1L, + RestParams(isForceReadFromNetwork = false) + ) + } returns DataResult.Fail() + + val result = repository.getFrontPage(1L, false) + Assert.assertNull(result) + } } diff --git a/apps/parent/src/test/java/com/instructure/parentapp/features/courses/details/CourseDetailsViewModelTest.kt b/apps/parent/src/test/java/com/instructure/parentapp/features/courses/details/CourseDetailsViewModelTest.kt index 2075a2bc6f..0984ada5ee 100644 --- a/apps/parent/src/test/java/com/instructure/parentapp/features/courses/details/CourseDetailsViewModelTest.kt +++ b/apps/parent/src/test/java/com/instructure/parentapp/features/courses/details/CourseDetailsViewModelTest.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.SavedStateHandle import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.canvasapi2.models.Page import com.instructure.canvasapi2.models.Tab import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.type.EnrollmentType @@ -98,8 +99,9 @@ class CourseDetailsViewModelTest { @Test fun `Load course details with front page tab`() = runTest { - coEvery { repository.getCourse(1, any()) } returns Course(id = 1, name = "Course 1", homePage = Course.HomePage.HOME_WIKI) + coEvery { repository.getCourse(1, any()) } returns Course(id = 1, name = "Course 1") coEvery { repository.getCourseTabs(1, any()) } returns listOf(Tab("tab1")) + coEvery { repository.getFrontPage(1, any()) } returns Page(body = "Front page content") createViewModel() @@ -115,6 +117,92 @@ class CourseDetailsViewModelTest { Assert.assertEquals(expected, viewModel.uiState.value) } + @Test + fun `Front page tab not shown when front page body is empty`() = runTest { + coEvery { repository.getCourse(1, any()) } returns Course(id = 1, name = "Course 1") + coEvery { repository.getCourseTabs(1, any()) } returns listOf(Tab("tab1")) + coEvery { repository.getFrontPage(1, any()) } returns Page(body = "") + + createViewModel() + + val expected = CourseDetailsUiState( + courseName = "Course 1", + studentColor = 1, + isLoading = false, + isError = false, + tabs = listOf(TabType.GRADES), + baseUrl = "domain/courses/1" + ) + + Assert.assertEquals(expected, viewModel.uiState.value) + } + + @Test + fun `Front page tab not shown when no front page`() = runTest { + coEvery { repository.getCourse(1, any()) } returns Course(id = 1, name = "Course 1") + coEvery { repository.getCourseTabs(1, any()) } returns listOf(Tab("tab1")) + coEvery { repository.getFrontPage(1, any()) } returns null + + createViewModel() + + val expected = CourseDetailsUiState( + courseName = "Course 1", + studentColor = 1, + isLoading = false, + isError = false, + tabs = listOf(TabType.GRADES), + baseUrl = "domain/courses/1" + ) + + Assert.assertEquals(expected, viewModel.uiState.value) + } + + @Test + fun `Front page tab shown when default view is not front page`() = runTest { + coEvery { repository.getCourse(1, any()) } returns Course(id = 1, name = "Course 1", homePage = Course.HomePage.HOME_ASSIGNMENTS) + coEvery { repository.getCourseTabs(1, any()) } returns listOf(Tab("tab1")) + coEvery { repository.getFrontPage(1, any()) } returns Page(body = "Front page content") + + createViewModel() + + val expected = CourseDetailsUiState( + courseName = "Course 1", + studentColor = 1, + isLoading = false, + isError = false, + tabs = listOf(TabType.GRADES, TabType.FRONT_PAGE), + baseUrl = "domain/courses/1" + ) + + Assert.assertEquals(expected, viewModel.uiState.value) + } + + @Test + fun `Front page tab shown alongside syllabus tab`() = runTest { + coEvery { repository.getCourse(1, any()) } returns Course( + id = 1, + name = "Course 1", + homePage = Course.HomePage.HOME_SYLLABUS, + syllabusBody = "Syllabus body" + ) + coEvery { repository.getCourseTabs(1, any()) } returns listOf(Tab(Tab.SYLLABUS_ID)) + coEvery { repository.getFrontPage(1, any()) } returns Page(body = "Front page content") + + createViewModel() + + val expected = CourseDetailsUiState( + courseName = "Course 1", + studentColor = 1, + isLoading = false, + isError = false, + tabs = listOf(TabType.GRADES, TabType.FRONT_PAGE, TabType.SYLLABUS), + syllabus = "Syllabus body", + baseUrl = "domain/courses/1" + ) + + Assert.assertEquals(expected, viewModel.uiState.value) + } + @Test fun `Load course details with syllabus tab`() = runTest { coEvery { repository.getCourse(1, any()) } returns Course( diff --git a/apps/student/build.gradle b/apps/student/build.gradle index ca660cb254..27bd9e9bf2 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -38,8 +38,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 287 - versionName = '8.5.0' + versionCode = 289 + versionName = '8.6.1' vectorDrawables.useSupportLibrary = true testInstrumentationRunner 'com.instructure.student.espresso.StudentHiltTestRunner' @@ -318,8 +318,6 @@ dependencies { implementation Libs.ANDROIDX_WORK_MANAGER implementation Libs.ANDROIDX_WORK_MANAGER_KTX - implementation Libs.PENDO - /* ROOM */ implementation Libs.ROOM ksp Libs.ROOM_COMPILER diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/DashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/DashboardE2ETest.kt index aab20d2faf..3c28993d7f 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/DashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/DashboardE2ETest.kt @@ -23,6 +23,7 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E +import com.instructure.canvas.espresso.annotations.Stub import com.instructure.dataseeding.api.ConversationsApi import com.instructure.dataseeding.api.GroupsApi import com.instructure.student.ui.utils.StudentTest @@ -39,6 +40,7 @@ class DashboardE2ETest : StudentTest() { @E2E @Test + @Stub("MBL-19868") @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E) fun testDashboardE2E() { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/DiscussionsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/DiscussionsE2ETest.kt index 2a12228bf0..e82d90f94f 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/DiscussionsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/DiscussionsE2ETest.kt @@ -19,6 +19,7 @@ package com.instructure.student.ui.e2e.classic import android.os.SystemClock.sleep import android.util.Log import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory @@ -27,16 +28,20 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E import com.instructure.canvas.espresso.pressBackButton import com.instructure.dataseeding.api.DiscussionTopicsApi -import com.instructure.dataseeding.api.EnrollmentsApi -import com.instructure.dataseeding.model.EnrollmentTypes.STUDENT_ENROLLMENT -import com.instructure.dataseeding.model.EnrollmentTypes.TEACHER_ENROLLMENT +import com.instructure.dataseeding.api.FileFolderApi +import com.instructure.dataseeding.api.FileUploadsApi +import com.instructure.dataseeding.model.FileUploadType import com.instructure.espresso.convertIso8601ToCanvasFormat +import com.instructure.espresso.getCustomDateCalendar import com.instructure.espresso.getDateInCanvasFormat import com.instructure.student.ui.utils.StudentComposeTest import com.instructure.student.ui.utils.extensions.seedData import com.instructure.student.ui.utils.extensions.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone @HiltAndroidTest class DiscussionsE2ETest: StudentComposeTest() { @@ -179,12 +184,6 @@ class DiscussionsE2ETest: StudentComposeTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Enroll '${student.name}' student to the dedicated course (${course.name}) with '${course.id}' id.") - EnrollmentsApi.enrollUser(course.id, student.id, STUDENT_ENROLLMENT) - - Log.d(PREPARATION_TAG, "Enroll '${teacher.name}' teacher to the dedicated course (${course.name}) with '${course.id}' id.") - EnrollmentsApi.enrollUser(course.id, teacher.id, TEACHER_ENROLLMENT) - Log.d(PREPARATION_TAG, "Seed a discussion topic with checkpoints for '${course.name}' course.") val discussionWithCheckpointsWithoutDueDatesTitle = "Test Discussion with Checkpoints" val assignmentName = "Test Assignment with Checkpoints" @@ -249,14 +248,11 @@ class DiscussionsE2ETest: StudentComposeTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DISCUSSIONS, TestCategory.E2E, SecondaryFeatureCategory.DISCUSSION_CHECKPOINTS) fun testDiscussionCheckpointsSyllabusE2E() { Log.d(PREPARATION_TAG, "Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1, syllabusBody = "this is the syllabus body") // This course and syllabus will be used once the seeding will be fixed + val data = seedData(students = 1, teachers = 1, courses = 1, syllabusBody = "this is the syllabus body") val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG, "Enroll '${student.name}' student to the dedicated course (${course.name}) with '$course.id' id.") - EnrollmentsApi.enrollUser(course.id, student.id, STUDENT_ENROLLMENT) - Log.d(PREPARATION_TAG, "Seed a discussion topic with checkpoints for '${course.name}' course.") val discussionWithCheckpointsTitle = "Test Discussion with Checkpoints" val replyToTopicDueDate = "2029-11-12T22:59:00Z" @@ -300,4 +296,159 @@ class DiscussionsE2ETest: StudentComposeTest() { assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Reply to topic due", convertedReplyToTopicDueDate) assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Additional replies (2) due",convertedReplyToEntryDueDate) } + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DISCUSSIONS, TestCategory.E2E, SecondaryFeatureCategory.DISCUSSION_CHECKPOINTS) + fun testDiscussionCheckpointWithPdfAttachmentE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Get course root folder to upload the PDF file.") + val courseRootFolder = FileFolderApi.getCourseRootFolder(course.id, teacher.token) + + Log.d(PREPARATION_TAG, "Read PDF file from assets.") + val pdfFileName = "samplepdf.pdf" + val context = InstrumentationRegistry.getInstrumentation().context + val pdfBytes = context.assets.open(pdfFileName).use { it.readBytes() } + + Log.d(PREPARATION_TAG, "Upload PDF file to course root folder using teacher token.") + val uploadedFile = FileUploadsApi.uploadFile(courseId = courseRootFolder.id, assignmentId = null, file = pdfBytes, fileName = pdfFileName, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) + + Log.d(PREPARATION_TAG, "Seed a discussion topic with checkpoints and PDF attachment for '${course.name}' course.") + val discussionWithCheckpointsTitle = "Discussion with PDF Attachment" + val assignmentName = "Assignment with Checkpoints and PDF" + val replyToTopicDueDate = "2029-11-12T22:59:00Z" + val replyToEntryDueDate = "2029-11-19T22:59:00Z" + DiscussionTopicsApi.createDiscussionTopicWithCheckpoints(courseId = course.id, token = teacher.token, discussionTitle = discussionWithCheckpointsTitle, assignmentName = assignmentName, replyToTopicDueDate = replyToTopicDueDate, replyToEntryDueDate = replyToEntryDueDate, fileId = uploadedFile.id.toString()) + + val convertedReplyToTopicDueDate = "Due " + convertIso8601ToCanvasFormat("2029-11-12T22:59:00Z") + " 2:59 PM" + val convertedReplyToEntryDueDate = "Due " + convertIso8601ToCanvasFormat("2029-11-19T22:59:00Z") + " 2:59 PM" + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Select course: '${course.name}'.") + dashboardPage.waitForRender() + dashboardPage.selectCourse(course.name) + + Log.d(ASSERTION_TAG, "Assert that the 'Discussions' Tab is displayed on the CourseBrowser Page.") + courseBrowserPage.assertTabDisplayed("Discussions") + + Log.d(STEP_TAG, "Navigate to Assignments Page.") + courseBrowserPage.selectAssignments() + + Log.d(ASSERTION_TAG, "Assert that the '${discussionWithCheckpointsTitle}' discussion is present along with 2 date info (For the 2 checkpoints).") + assignmentListPage.assertHasAssignmentWithCheckpoints(discussionWithCheckpointsTitle, dueAtString = convertedReplyToTopicDueDate, dueAtStringSecondCheckpoint = convertedReplyToEntryDueDate, expectedGrade = "-/15") + + Log.d(STEP_TAG, "Click on '$discussionWithCheckpointsTitle' assignment.") + assignmentListPage.clickAssignment(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that Assignment Details Page is displayed with correct title.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertAssignmentTitle(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that attachment icon is displayed.") + assignmentDetailsPage.assertAttachmentIconDisplayed() + + Log.d(STEP_TAG, "Click on attachment icon to view attachments.") + assignmentDetailsPage.clickAttachmentIcon() + + Log.d(ASSERTION_TAG, "Verify PDF viewer toolbar is displayed.") + assignmentDetailsPage.assertPdfViewerToolbarDisplayed() + + Log.d(STEP_TAG, "Navigate back from PDF viewer to assignment details.") + Espresso.pressBack() + + Log.d(ASSERTION_TAG, "Assert that we're back on the assignment details page.") + assignmentDetailsPage.assertAssignmentTitle(discussionWithCheckpointsTitle) + } + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DISCUSSIONS, TestCategory.E2E, SecondaryFeatureCategory.DISCUSSION_CHECKPOINTS) + fun testDiscussionCheckpointsCalendarE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1, syllabusBody = "this is the syllabus body") + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + val discussionWithCheckpointsTitle = "Test Discussion with Checkpoints" + val assignmentName = "Test Assignment with Checkpoints" + + Log.d(PREPARATION_TAG, "Convert dates to match with different formats in different screens (Calendar, Assignment Details)") + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val calendarDisplayFormat = SimpleDateFormat(" MMM d 'at' h:mm a", Locale.US) + val assignmentDetailsDisplayFormat = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.US) + val replyToTopicCalendar = getCustomDateCalendar(2) + val replyToEntryCalendar = getCustomDateCalendar(4) + val replyToTopicDueTime = dateFormat.format(replyToTopicCalendar.time) + val replyToEntryDueTime = dateFormat.format(replyToEntryCalendar.time) + val convertedReplyToTopicDueDate = calendarDisplayFormat.format(replyToTopicCalendar.time) + val convertedReplyToEntryDueDate = calendarDisplayFormat.format(replyToEntryCalendar.time) + val assignmentDetailsReplyToTopicDueDate = assignmentDetailsDisplayFormat.format(replyToTopicCalendar.time) + val assignmentDetailsReplyToEntryDueDate = assignmentDetailsDisplayFormat.format(replyToEntryCalendar.time) + + Log.d(PREPARATION_TAG, "Seed a discussion topic with checkpoints for '${course.name}' course.") + DiscussionTopicsApi.createDiscussionTopicWithCheckpoints(course.id, teacher.token, discussionWithCheckpointsTitle, assignmentName, replyToTopicDueTime, replyToEntryDueTime) + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Click on the 'Calendar' bottom menu to navigate to the Calendar page.") + dashboardPage.clickCalendarTab() + + Log.d(STEP_TAG , "Swipe 2 days to the future to find the 'Reply to Topic' Discussion Checkpoint calendar item.") + calendarScreenPage.swipeEventsLeft(2) + + Log.d(ASSERTION_TAG, "Assert that the 'Reply to Topic' checkpoint is displayed on the Calendar Page with the correct title, course name, and due date.") + calendarScreenPage.assertItemDetails(discussionWithCheckpointsTitle, course.name, "Due$convertedReplyToTopicDueDate") + + Log.d(STEP_TAG , "Swipe 2 additional days to the future to find the 'Additional replies' Discussion Checkpoint calendar item.") + calendarScreenPage.swipeEventsLeft(2) + + Log.d(ASSERTION_TAG, "Assert that the 'Additional replies' checkpoint is displayed on the Calendar Page with the correct title, course name, and due date.") + calendarScreenPage.assertItemDetails(discussionWithCheckpointsTitle, course.name,"Due$convertedReplyToEntryDueDate") + + Log.d(STEP_TAG, "Click on the '$discussionWithCheckpointsTitle' discussion's 'Additional replies' checkpoint calendar item to open it's details.") + calendarScreenPage.clickOnItem(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that the Assignment Details Page is displayed properly with the correct toolbar title and subtitle.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertDisplayToolbarSubtitle(course.name) + + Log.d(ASSERTION_TAG, "Assert that the checkpoints are displayed properly on the Assignment Details Page.") + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Reply to topic due", assignmentDetailsReplyToTopicDueDate) + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Additional replies (2) due", assignmentDetailsReplyToEntryDueDate) + + Log.d(STEP_TAG, "Navigate back to Calendar Page.") + Espresso.pressBack() + + Log.d(STEP_TAG , "Swipe 2 days BACK to find the 'Reply to Topic' Discussion Checkpoint calendar item again.") + calendarScreenPage.swipeEventsRight(2) + + Log.d(ASSERTION_TAG, "Assert that the 'Reply to Topic' checkpoint is displayed on the Calendar Page with the correct title, course name, and due date.") + calendarScreenPage.assertItemDetails(discussionWithCheckpointsTitle, course.name, "Due$convertedReplyToTopicDueDate") + + Log.d(STEP_TAG, "Click on the '$discussionWithCheckpointsTitle' discussion's 'Reply to Topic' checkpoint calendar item to open it's details.") + calendarScreenPage.clickOnItem(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that the Assignment Details Page is displayed properly with the correct toolbar title and subtitle.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertDisplayToolbarSubtitle(course.name) + + Log.d(ASSERTION_TAG, "Assert that the checkpoints are displayed properly on the Assignment Details Page.") + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Reply to topic due", assignmentDetailsReplyToTopicDueDate) + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Additional replies (2) due", assignmentDetailsReplyToEntryDueDate) + } + } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/FilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/FilesE2ETest.kt index 03fd6afebe..b679ab3742 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/FilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/FilesE2ETest.kt @@ -17,7 +17,9 @@ package com.instructure.student.ui.e2e.classic import android.os.Environment +import android.os.SystemClock.sleep import android.util.Log +import androidx.media3.ui.R import androidx.test.espresso.Espresso import androidx.test.espresso.intent.Intents import androidx.test.platform.app.InstrumentationRegistry @@ -26,7 +28,6 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E -import com.instructure.canvas.espresso.common.pages.compose.AssignmentListPage import com.instructure.canvas.espresso.pressBackButton import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.models.CanvasContext @@ -35,6 +36,8 @@ import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.DiscussionTopicsApi +import com.instructure.dataseeding.api.FileFolderApi +import com.instructure.dataseeding.api.FileUploadsApi import com.instructure.dataseeding.api.SubmissionsApi import com.instructure.dataseeding.model.FileUploadType import com.instructure.dataseeding.model.GradingType @@ -43,6 +46,9 @@ import com.instructure.dataseeding.util.Randomizer import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 +import com.instructure.espresso.getVideoPosition +import com.instructure.espresso.retryWithIncreasingDelay +import com.instructure.espresso.triggerWorkManagerJobs import com.instructure.student.ui.utils.StudentComposeTest import com.instructure.student.ui.utils.extensions.seedData import com.instructure.student.ui.utils.extensions.tokenLogin @@ -310,4 +316,129 @@ class FilesE2ETest: StudentComposeTest() { fileListPage.assertItemDisplayed(testFile) } + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.FILES, TestCategory.E2E) + fun testVideoFileUploadE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val videoFileName = "test_video.mp4" + + Log.d(PREPARATION_TAG, "Setup the '$videoFileName' file on the device.") + setupFileOnDevice(videoFileName) + + Log.d(PREPARATION_TAG, "Seed '$videoFileName' to the course root folder via the teacher so the Files tab is visible and the video can be opened by the student.") + val courseRootFolder = FileFolderApi.getCourseRootFolder(course.id, teacher.token) + val videoFileBytes = InstrumentationRegistry.getInstrumentation().context.assets.open(videoFileName).readBytes() + FileUploadsApi.uploadFile(courseId = courseRootFolder.id, assignmentId = null, file = videoFileBytes, fileName = videoFileName, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Navigate to the global 'Files' Page from the left side menu.") + leftSideNavigationDrawerPage.clickFilesMenu() + + Log.d(STEP_TAG, "Click on the 'Add' (+) icon and after that on the 'Upload File' icon.") + fileListPage.clickAddButton() + fileListPage.clickUploadFileButton() + + Log.d(ASSERTION_TAG, "Assert that the File Chooser Page title is 'Upload To My Files'.") + fileChooserPage.assertDialogTitle("Upload To My Files") + + Log.d(PREPARATION_TAG, "Simulate file picker intent for '$videoFileName'.") + Intents.init() + try { + stubFilePickerIntent(videoFileName) + fileChooserPage.chooseDevice() + } finally { + Intents.release() + } + + Log.d(STEP_TAG, "Click on the 'Upload' button.") + fileChooserPage.clickUpload() + + Log.d(ASSERTION_TAG, "Assert that '$videoFileName' is displayed in My Files after the upload.") + retryWithIncreasingDelay(times = 10, maxDelay = 3000, catchBlock = { + triggerWorkManagerJobs("FileUploadWorker", 20000) + }) { + fileListPage.assertItemDisplayed(videoFileName) + } + + Log.d(STEP_TAG, "Click on '$videoFileName' to open it.") + fileListPage.selectItem(videoFileName) + + Log.d(ASSERTION_TAG, "Assert that the video player is displayed.") + videoPlayerPage.waitForPlayerViewAndTapToShowControls(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the video.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current video position.") + var firstVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "First video position: $firstVideoPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the video position again.") + var secondVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "Second video position: $secondVideoPositionText") + + Log.d(ASSERTION_TAG, "Assert that the video position has changed, confirming video is playing.") + assert(firstVideoPositionText != secondVideoPositionText) { + "Video position did not change. First: $firstVideoPositionText, Second: $secondVideoPositionText" + } + + Log.d(STEP_TAG, "Navigate back to Dashboard Page.") + pressBackButton(2) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select '${course.name}' course and navigate to the course files.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectFiles() + + Log.d(ASSERTION_TAG, "Assert that '$videoFileName' is displayed in the course files.") + fileListPage.assertItemDisplayed(videoFileName) + + Log.d(STEP_TAG, "Click on '$videoFileName' to open it.") + fileListPage.selectItem(videoFileName) + + Log.d(ASSERTION_TAG, "Assert that the video player is displayed.") + videoPlayerPage.waitForPlayerViewAndTapToShowControls(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the video.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current video position.") + firstVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "First video position: $firstVideoPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the video position again.") + secondVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "Second video position: $secondVideoPositionText") + + Log.d(ASSERTION_TAG, "Assert that the video position has changed, confirming video is playing.") + assert(firstVideoPositionText != secondVideoPositionText) { + "Video position did not change. First: $firstVideoPositionText, Second: $secondVideoPositionText" + } + } + } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/LoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/LoginE2ETest.kt index 5680ac92aa..d083081081 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/LoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/LoginE2ETest.kt @@ -16,8 +16,11 @@ */ package com.instructure.student.ui.e2e.classic +import android.app.Instrumentation import android.util.Log import androidx.test.espresso.Espresso +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory @@ -37,6 +40,7 @@ import com.instructure.dataseeding.model.EnrollmentTypes.STUDENT_ENROLLMENT import com.instructure.dataseeding.model.EnrollmentTypes.TEACHER_ENROLLMENT import com.instructure.dataseeding.util.CanvasNetworkAdapter import com.instructure.espresso.withIdlingResourceDisabled +import com.instructure.student.R import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.extensions.enterDomain import com.instructure.student.ui.utils.extensions.seedData @@ -251,13 +255,15 @@ class LoginE2ETest : StudentTest() { @E2E @Test + @Stub("MBL-19866") @TestMetaData(Priority.IMPORTANT, FeatureCategory.LOGIN, TestCategory.E2E) fun testInvalidAndEmptyLoginCredentialsE2E() { val INVALID_USERNAME = "invalidusercred@test.com" val INVALID_PASSWORD = "invalidpw" - val INVALID_CREDENTIALS_ERROR_MESSAGE = "Please verify your username or password and try again. Trouble logging in? Check out our Login FAQs." - val NO_PASSWORD_GIVEN_ERROR_MESSAGE = "No password was given" + val INVALID_CREDENTIALS_ERROR_MESSAGE = "Please verify your username or password and try again." + val NO_EMAIL_GIVEN_ERROR_MESSAGE = "Please enter your email." + val NO_PASSWORD_GIVEN_ERROR_MESSAGE = "Please enter your password." val DOMAIN = "mobileqa.beta" Log.d(STEP_TAG, "Click 'Find My School' button.") @@ -269,29 +275,32 @@ class LoginE2ETest : StudentTest() { Log.d(STEP_TAG, "Click on 'Next' button on the Toolbar.") loginFindSchoolPage.clickToolbarNextMenuItem() + /* Somehow React does not recognize the invalid credentials, need to be fixed in follow-up ticket Log.d(STEP_TAG, "Try to login with invalid, non-existing credentials ('$INVALID_USERNAME', '$INVALID_PASSWORD').") loginSignInPage.loginAs(INVALID_USERNAME, INVALID_PASSWORD) Log.d(ASSERTION_TAG, "Assert that the invalid credentials error message is displayed.") - loginSignInPage.assertLoginErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) + loginSignInPage.assertLoginEmailErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) // Invalid credentials error message will be displayed within the email error message holder on the login page. + */ Log.d(STEP_TAG, "Try to login with no credentials typed in either of the username and password field.") loginSignInPage.loginAs(EMPTY_STRING, EMPTY_STRING) - Log.d(ASSERTION_TAG, "Assert that the no password was given error message is displayed.") - loginSignInPage.assertLoginErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) + Log.d(ASSERTION_TAG, "Assert that the no email and no password error messages are displayed.") + loginSignInPage.assertLoginEmailErrorMessage(NO_EMAIL_GIVEN_ERROR_MESSAGE) + loginSignInPage.assertLoginPasswordErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) Log.d(STEP_TAG, "Try to login with leaving only the password field empty.") loginSignInPage.loginAs(INVALID_USERNAME, EMPTY_STRING) Log.d(ASSERTION_TAG, "Assert that the no password was given error message is displayed.") - loginSignInPage.assertLoginErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) + loginSignInPage.assertLoginEmailErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) Log.d(STEP_TAG, "Try to login with leaving only the username field empty.") loginSignInPage.loginAs(EMPTY_STRING, INVALID_PASSWORD) - Log.d(ASSERTION_TAG, "Assert that the invalid credentials error message is displayed.") - loginSignInPage.assertLoginErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) + Log.d(ASSERTION_TAG, "Assert that the no email error message is displayed.") + loginSignInPage.assertLoginEmailErrorMessage(NO_EMAIL_GIVEN_ERROR_MESSAGE) // Invalid credentials error message will be displayed within the email error message holder on the login page. } // Verify that students can sign into vanity domain @@ -416,6 +425,58 @@ class LoginE2ETest : StudentTest() { loginSignInPage.assertPageObjects() } + @E2E + @Test + @TestMetaData(Priority.NICE_TO_HAVE, FeatureCategory.LOGIN, TestCategory.E2E) + fun testLoginHowDoIFindMySchoolE2E() { + + Log.d(STEP_TAG, "Click 'Find My School' button.") + loginLandingPage.clickFindMySchoolButton() + + Log.d(STEP_TAG, "Enter and invalid domain to trigger the 'Tap here for login help.' link to be displayed.") + loginFindSchoolPage.enterDomain("invalid-domain") + + Log.d(ASSERTION_TAG, "Assert that the 'Tap here for login help.' link is displayed.") + loginFindSchoolPage.assertHowDoIFindMySchoolLinkDisplayed() + + val expectedUrl = "https://community.instructure.com/en/kb/articles/662717-where-do-i-find-my-institutions-url-to-access-canvas" + val expectedIntent = IntentMatchers.hasData(expectedUrl) + Intents.init() + try { + Intents.intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null)) + + Log.d(STEP_TAG, "Click on the 'Tap here for login help.' link.") + loginFindSchoolPage.clickOnHowDoIFindMySchoolLink() + + Log.d(ASSERTION_TAG, "Assert that an intent with the correct URL was fired.") + Intents.intended(expectedIntent) + } finally { + Intents.release() + } + } + + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.LOGIN, TestCategory.E2E) + fun testLoginCanFindSchoolE2E() { + + Log.d(STEP_TAG, "Click 'Find My School' button.") + loginLandingPage.clickFindMySchoolButton() + + Log.d(ASSERTION_TAG, "Assert that the Find School Page has been displayed properly.") + loginFindSchoolPage.assertPageObjects() + + Log.d(ASSERTION_TAG, "Assert that the hint text is correct based on the device type.") + if(isTabletDevice()) loginFindSchoolPage.assertHintText(R.string.schoolInstructureCom) + else loginFindSchoolPage.assertHintText(R.string.loginHint) + + Log.d(STEP_TAG, "Enter domain: 'harvest'.") + loginFindSchoolPage.enterDomain("harvest") + + Log.d(ASSERTION_TAG, "Assert that the 'City Harvest Church (Singapore)' school is displayed among the search results.") + loginFindSchoolPage.assertSchoolSearchResults("City Harvest Church (Singapore)") + } + private fun loginWithUser(user: CanvasUserApiModel, lastSchoolSaved: Boolean = false) { Thread.sleep(5100) //Need to wait > 5 seconds before each login attempt because of new 'too many attempts' login policy on web. diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/TodoE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/TodoE2ETest.kt index b8c8fd29a0..e572c10749 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/TodoE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/TodoE2ETest.kt @@ -5,6 +5,7 @@ import androidx.test.espresso.Espresso import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E @@ -12,6 +13,7 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.toApiString import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.CalendarEventApi +import com.instructure.dataseeding.api.DiscussionTopicsApi import com.instructure.dataseeding.api.PlannerAPI import com.instructure.dataseeding.api.QuizzesApi import com.instructure.dataseeding.model.GradingType @@ -20,6 +22,8 @@ import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 +import com.instructure.espresso.getCustomDateCalendar +import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.pandautils.R import com.instructure.student.ui.pages.classic.WebViewTextCheck import com.instructure.student.ui.utils.StudentComposeTest @@ -27,8 +31,11 @@ import com.instructure.student.ui.utils.extensions.seedData import com.instructure.student.ui.utils.extensions.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test +import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date +import java.util.Locale +import java.util.TimeZone @HiltAndroidTest class TodoE2ETest : StudentComposeTest() { @@ -104,7 +111,7 @@ class TodoE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) - Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Navigate to 'To Do' Page via bottom-menu.") + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Navigate to 'To-do' Page via bottom-menu.") dashboardPage.waitForRender() dashboardPage.clickTodoTab() @@ -119,17 +126,17 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(threeWeeksAwayQuiz.title) toDoListPage.assertItemNotDisplayed(fourWeeksAwayAssignment.name) - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() - Log.d(ASSERTION_TAG, "Assert that the To Do Filter Page is displayed with the correct title.") + Log.d(ASSERTION_TAG, "Assert that the To-do Filter Page is displayed with the correct title.") toDoFilterPage.assertFilterScreenTitle() Log.d(ASSERTION_TAG, "Assert that all filter options (and their 'group labels') are displayed correctly on the ToDo Filter Page.") toDoFilterPage.assertToDoFilterScreenDetails() Log.d(ASSERTION_TAG, "Assert that all 'Visible items' filter options are disabled by default on the ToDo Filter Page.") - toDoFilterPage.assertVisibleItemOptionCheckedState(R.string.todoFilterShowPersonalToDos, false) + toDoFilterPage.assertVisibleItemOptionCheckedState(R.string.todoFilterShowPersonalToDosNew, false) toDoFilterPage.assertVisibleItemOptionCheckedState(R.string.todoFilterShowCalendarEvents, false) toDoFilterPage.assertVisibleItemOptionCheckedState(R.string.todoFilterShowCompleted, false) toDoFilterPage.assertVisibleItemOptionCheckedState(R.string.todoFilterFavoriteCoursesOnly, false) @@ -161,7 +168,7 @@ class TodoE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Mark the '${todayQuiz.title}' quiz as done.") toDoListPage.clickMarkToDoItemAsDone(todayQuiz.id) - Log.d(ASSERTION_TAG, "Assert that the snack bar is displayed with the correct quiz title. Wait until the item disappears from the To Do List.") + Log.d(ASSERTION_TAG, "Assert that the snack bar is displayed with the correct quiz title. Wait until the item disappears from the To-do List.") toDoListPage.waitForSnackbar(todayQuiz.title) toDoListPage.assertSnackbarDisplayed(todayQuiz.title) toDoListPage.waitForItemToDisappear(todayQuiz.title) @@ -169,18 +176,18 @@ class TodoE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Click on 'Undo' button on the snack bar.") toDoListPage.clickSnackbarUndo() - Log.d(ASSERTION_TAG, "Assert that the '${todayQuiz.title}' quiz is back on the To Do List as we reverted the marking as done activity by clicked on the 'Undo' on the snack bar.") + Log.d(ASSERTION_TAG, "Assert that the '${todayQuiz.title}' quiz is back on the To-do List as we reverted the marking as done activity by clicked on the 'Undo' on the snack bar.") toDoListPage.waitForItemToAppear(todayQuiz.title) toDoListPage.assertItemDisplayed(todayQuiz.title) Log.d(STEP_TAG, "Mark the '${todayQuiz.title}' quiz as done.") toDoListPage.clickMarkToDoItemAsDone(todayQuiz.id) - Log.d(ASSERTION_TAG, "Assert that the To Do List Page is empty because of the (default) filters.") + Log.d(ASSERTION_TAG, "Assert that the To-do List Page is empty because of the (default) filters.") toDoListPage.waitForItemToDisappear(todayQuiz.title) toDoListPage.assertEmptyState() - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(ASSERTION_TAG, "Assert that the 'Show tasks until' filter has the 'Today' option selected.") @@ -190,10 +197,10 @@ class TodoE2ETest : StudentComposeTest() { toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowCompleted) toDoFilterPage.clickClose() - Log.d(ASSERTION_TAG, "Assert that the To Do List Page is still empty because we did not save the 'Completed' filter by clicking on the 'Done' button, rather we clicked on the Close (X) button so the changes won't be applied.") + Log.d(ASSERTION_TAG, "Assert that the To-do List Page is still empty because we did not save the 'Completed' filter by clicking on the 'Done' button, rather we clicked on the Close (X) button so the changes won't be applied.") toDoListPage.assertEmptyState() - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Select the 'Completed' visible items filter and click on 'Done' button.") @@ -205,7 +212,7 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(favoriteCourseAssignment.name) toDoListPage.assertItemNotDisplayed(thisWeekAssignment.name) - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "UNselect the 'Completed' visible items filter and set back the 'Show tasks until' filter to 'This Week' (default) option and click on 'Done' button.") @@ -238,10 +245,10 @@ class TodoE2ETest : StudentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the unfavorited course, '${course.name}' is NOT displayed on the Dashboard Page.") dashboardPage.assertCourseNotDisplayed(course) - Log.d(STEP_TAG, "Navigate to 'To Do' Page via bottom-menu.") + Log.d(STEP_TAG, "Navigate to 'To-do' Page via bottom-menu.") dashboardPage.clickTodoTab() - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Select the 'Favorite Courses Only' visible items filter and click on 'Done' button.") @@ -256,7 +263,7 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(nextWeekQuiz.title) //Check next week in future - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Unselect the 'Favorite Courses Only' visible items filter and change the 'Show tasks until' filter to 'Next Week' option and click on 'Done' button") @@ -276,7 +283,7 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(todayQuiz.title) // Not displayed as it's completed. //Check 2 weeks in future - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Change the 'Show tasks until' filter to 'In Two Weeks' option and click on 'Done' button.") @@ -295,7 +302,7 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(todayQuiz.title) // Not displayed as it's completed. //Check 3 weeks in future - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Change the 'Show tasks until' filter to 'In Three Weeks' option and click on 'Done' button.") @@ -314,7 +321,7 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(todayQuiz.title) // Not displayed as it's completed. //Check 4 weeks in future - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Change the 'Show tasks until' filter to 'In Four Weeks' option and click on 'Done' button.") @@ -338,7 +345,7 @@ class TodoE2ETest : StudentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the '${thisWeekAssignment.name}' assignment details page is displayed with the correct assignment details.") assignmentDetailsPage.assertAssignmentDetails(thisWeekAssignment) - Log.d(STEP_TAG, "Navigate back to the To Do List Page.") + Log.d(STEP_TAG, "Navigate back to the To-do List Page.") Espresso.pressBack() Log.d(STEP_TAG, "Click on '${nextWeekQuiz.title}' quiz to open its details page.") @@ -405,7 +412,7 @@ class TodoE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) - Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Navigate to 'To Do' Page via bottom-menu.") + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Navigate to 'To-do' Page via bottom-menu.") dashboardPage.waitForRender() dashboardPage.clickTodoTab() @@ -419,10 +426,10 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemDisplayed(fourWeeksAgoAssignment.name) //Check 3 weeks in past - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() - Log.d(ASSERTION_TAG, "Assert that the To Do Filter Page is displayed with the correct title.") + Log.d(ASSERTION_TAG, "Assert that the To-do Filter Page is displayed with the correct title.") toDoFilterPage.assertFilterScreenTitle() Log.d(ASSERTION_TAG, "Assert that the 'Show tasks from' filter has the '4 Weeks Ago' option selected as default.") @@ -443,7 +450,7 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(fourWeeksAgoAssignment.name) //Check 2 weeks in past - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Change the 'Show tasks from' filter to '2 Weeks Ago' option and click on 'Done' button.") @@ -461,7 +468,7 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(threeWeeksAgoQuiz.title) //Check 1 week in past (Last Week) - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Change the 'Show tasks from' filter to 'Last Week' option and click on 'Done' button.") @@ -479,7 +486,7 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(twoWeeksAgoAssignment.name) //Check this week in past (This Week) - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Change the 'Show tasks from' filter to 'This Week' option and click on 'Done' button.") @@ -497,7 +504,7 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.assertItemNotDisplayed(lastWeekQuiz.title) //Check Today in past filters (Today) - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Change the 'Show tasks from' filter to 'Today' option and click on 'Done' button.") @@ -534,7 +541,7 @@ class TodoE2ETest : StudentComposeTest() { Date().toApiString() ) - Log.d(PREPARATION_TAG, "Seed a personal To Do for '${student.name}' student.") + Log.d(PREPARATION_TAG, "Seed a personal To-do for '${student.name}' student.") val testCalendarPersonalToDo = PlannerAPI.createPlannerNote( student.token, "Student Test Personal ToDo", @@ -545,23 +552,23 @@ class TodoE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) - Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Navigate to 'To Do' Page via bottom-menu.") + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Navigate to 'To-do' Page via bottom-menu.") dashboardPage.waitForRender() dashboardPage.clickTodoTab() - Log.d(ASSERTION_TAG, "Assert that the To Do List Page is empty because of the (default) filters.") + Log.d(ASSERTION_TAG, "Assert that the To-do List Page is empty because of the (default) filters.") toDoListPage.assertEmptyState() - //Show both types: Calendar Events and Personal To Dos - Log.d(STEP_TAG, "Open the To Do Filter Page.") + //Show both types: Calendar Events and Personal To-dos + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() - Log.d(STEP_TAG, "Select the 'Show Personal To Dos' and 'Show Calendar Events' visible items filter and click on 'Done' button.") - toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowPersonalToDos) + Log.d(STEP_TAG, "Select the 'Show Personal To-dos' and 'Show Calendar Events' visible items filter and click on 'Done' button.") + toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowPersonalToDosNew) toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowCalendarEvents) toDoFilterPage.clickDone() - Log.d(ASSERTION_TAG, "Assert that '${testCalendarEvent.title}' calendar event and '${testCalendarPersonalToDo.title}' personal To Do are displayed because we filtered to Personal To Dos and calendar events.") + Log.d(ASSERTION_TAG, "Assert that '${testCalendarEvent.title}' calendar event and '${testCalendarPersonalToDo.title}' personal To-do are displayed because we filtered to Personal To-dos and calendar events.") toDoListPage.assertItemDisplayed(testCalendarEvent.title) toDoListPage.assertItemDisplayed(testCalendarPersonalToDo.title) @@ -571,40 +578,40 @@ class TodoE2ETest : StudentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the '${testCalendarEvent.title}' calendar event details page is displayed with the correct event title.") calendarEventDetailsPage.assertEventTitle(testCalendarEvent.title) - Log.d(STEP_TAG, "Navigate back to the To Do List Page.") + Log.d(STEP_TAG, "Navigate back to the To-do List Page.") Espresso.pressBack() - Log.d(STEP_TAG, "Click on the '${testCalendarPersonalToDo.title}' personal To Do item to open its details page.") + Log.d(STEP_TAG, "Click on the '${testCalendarPersonalToDo.title}' personal To-do item to open its details page.") toDoListPage.clickOnItem(testCalendarPersonalToDo.title) - Log.d(ASSERTION_TAG, "Assert that the '${testCalendarPersonalToDo.title}' personal To Do details page is displayed with the correct event title.") + Log.d(ASSERTION_TAG, "Assert that the '${testCalendarPersonalToDo.title}' personal To-do details page is displayed with the correct event title.") calendarToDoDetailsPage.assertTitle(testCalendarPersonalToDo.title) - Log.d(STEP_TAG, "Navigate back to the To Do List Page.") + Log.d(STEP_TAG, "Navigate back to the To-do List Page.") Espresso.pressBack() - //Show only Personal To Dos - Log.d(STEP_TAG, "Open the To Do Filter Page.") + //Show only Personal To-dos + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() - Log.d(STEP_TAG, "Select the 'Show Personal To Dos' visible item filter and click on 'Done' button.") - toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowCalendarEvents) // Toggle off Calendar Events to show only Personal To Dos + Log.d(STEP_TAG, "Select the 'Show Personal To-dos' visible item filter and click on 'Done' button.") + toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowCalendarEvents) // Toggle off Calendar Events to show only Personal To-dos toDoFilterPage.clickDone() - Log.d(ASSERTION_TAG, "Assert that the '${testCalendarPersonalToDo.title}' personal To Do is displayed, BUT the '${testCalendarEvent.title}' calendar event is not according to current filters.") + Log.d(ASSERTION_TAG, "Assert that the '${testCalendarPersonalToDo.title}' personal To-do is displayed, BUT the '${testCalendarEvent.title}' calendar event is not according to current filters.") toDoListPage.assertItemDisplayed(testCalendarPersonalToDo.title) toDoListPage.assertItemNotDisplayed(testCalendarEvent.title) //Show only Calendar Events - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Select the 'Show Calendar Events' visible item filter and click on 'Done' button.") - toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowPersonalToDos) // Toggle off Personal To Dos + toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowPersonalToDosNew) // Toggle off Personal To-dos toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowCalendarEvents) // Toggle on Calendar Events toDoFilterPage.clickDone() - Log.d(ASSERTION_TAG, "Assert that the '${testCalendarEvent.title}' calendar event is displayed, BUT the '${testCalendarPersonalToDo.title}' personal To Do is not according to current filters.") + Log.d(ASSERTION_TAG, "Assert that the '${testCalendarEvent.title}' calendar event is displayed, BUT the '${testCalendarPersonalToDo.title}' personal To-do is not according to current filters.") toDoListPage.assertItemDisplayed(testCalendarEvent.title) toDoListPage.assertItemNotDisplayed(testCalendarPersonalToDo.title) @@ -612,22 +619,22 @@ class TodoE2ETest : StudentComposeTest() { val todayDayOfMonth = Calendar.getInstance().get(Calendar.DAY_OF_MONTH) toDoListPage.clickDateBadge(todayDayOfMonth) - Log.d(ASSERTION_TAG, "Assert that the Calendar Page is displayed with the correct title. Assert that both the previously created calendar event '${testCalendarEvent.title}' and personal To Do '${testCalendarPersonalToDo.title}' are displayed on the calendar for today.") + Log.d(ASSERTION_TAG, "Assert that the Calendar Page is displayed with the correct title. Assert that both the previously created calendar event '${testCalendarEvent.title}' and personal To-do '${testCalendarPersonalToDo.title}' are displayed on the calendar for today.") calendarScreenPage.assertCalendarPageTitle() calendarScreenPage.assertItemDisplayed(testCalendarPersonalToDo.title) calendarScreenPage.assertItemDisplayed(testCalendarEvent.title) - Log.d(STEP_TAG, "Navigate back to the To Do List Page.") + Log.d(STEP_TAG, "Navigate back to the To-do List Page.") Espresso.pressBack() - Log.d(STEP_TAG, "Open the To Do Filter Page.") + Log.d(STEP_TAG, "Open the To-do Filter Page.") toDoListPage.clickFilterButton() Log.d(STEP_TAG, "Select the 'Show Calendar Events' visible item filter and click on 'Done' button.") - toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowPersonalToDos) // Toggle off Personal To Dos + toDoFilterPage.selectVisibleItemsOption(R.string.todoFilterShowPersonalToDosNew) // Toggle off Personal To-dos toDoFilterPage.clickDone() - Log.d(ASSERTION_TAG, "Assert that '${testCalendarEvent.title}' calendar event and '${testCalendarPersonalToDo.title}' personal To Do are displayed because we filtered to Personal To Dos and calendar events.") + Log.d(ASSERTION_TAG, "Assert that '${testCalendarEvent.title}' calendar event and '${testCalendarPersonalToDo.title}' personal To-do are displayed because we filtered to Personal To-dos and calendar events.") toDoListPage.assertItemDisplayed(testCalendarEvent.title) toDoListPage.assertItemDisplayed(testCalendarPersonalToDo.title) @@ -638,32 +645,126 @@ class TodoE2ETest : StudentComposeTest() { toDoListPage.waitForSnackbar(testCalendarEvent.title) toDoListPage.assertSnackbarDisplayed(testCalendarEvent.title) - Log.d(ASSERTION_TAG, "Assert that '${testCalendarEvent.title}' calendar event IS NOT displayed anymore, but the '${testCalendarPersonalToDo.title}' personal To Do is displayed.") + Log.d(ASSERTION_TAG, "Assert that '${testCalendarEvent.title}' calendar event IS NOT displayed anymore, but the '${testCalendarPersonalToDo.title}' personal To-do is displayed.") toDoListPage.assertItemNotDisplayed(testCalendarEvent.title) toDoListPage.assertItemDisplayed(testCalendarPersonalToDo.title) - Log.d(STEP_TAG, "Swipe left on the '${testCalendarPersonalToDo.title}' personal To Do to mark it as done.") + Log.d(STEP_TAG, "Swipe left on the '${testCalendarPersonalToDo.title}' personal To-do to mark it as done.") toDoListPage.swipeItemLeft(testCalendarPersonalToDo.id!!.toLong()) - Log.d(ASSERTION_TAG, "Assert that the snack bar is displayed with the correct personal To Do title.") + Log.d(ASSERTION_TAG, "Assert that the snack bar is displayed with the correct personal To-do title.") toDoListPage.waitForSnackbar(testCalendarPersonalToDo.title) toDoListPage.assertSnackbarDisplayed(testCalendarPersonalToDo.title) Log.d(STEP_TAG, "Click on 'Undo' button on the snack bar to 'revert' the swipe action.") toDoListPage.clickSnackbarUndo() - Log.d(ASSERTION_TAG, "Assert that the '${testCalendarPersonalToDo.title}' personal To Do is back on the To Do List as we reverted the marking as done activity by clicked on the 'Undo' on the snack bar.") + Log.d(ASSERTION_TAG, "Assert that the '${testCalendarPersonalToDo.title}' personal To-do is back on the To-do List as we reverted the marking as done activity by clicked on the 'Undo' on the snack bar.") toDoListPage.waitForItemToAppear(testCalendarPersonalToDo.title) toDoListPage.assertItemDisplayed(testCalendarPersonalToDo.title) - Log.d(STEP_TAG, "Swipe left on the '${testCalendarPersonalToDo.title}' personal To Do to mark it as done.") + Log.d(STEP_TAG, "Swipe left on the '${testCalendarPersonalToDo.title}' personal To-do to mark it as done.") toDoListPage.swipeItemLeft(testCalendarPersonalToDo.id!!.toLong()) - Log.d(ASSERTION_TAG, "Assert that the snack bar is displayed with the correct personal To Do title.") + Log.d(ASSERTION_TAG, "Assert that the snack bar is displayed with the correct personal To-do title.") toDoListPage.waitForSnackbar(testCalendarPersonalToDo.title) toDoListPage.assertSnackbarDisplayed(testCalendarPersonalToDo.title) - Log.d(ASSERTION_TAG, "Assert that the To Do List Page is empty because of the (default) filters.") + Log.d(ASSERTION_TAG, "Assert that the To-do List Page is empty because of the (default) filters.") toDoListPage.assertEmptyState() } + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.TODOS, TestCategory.E2E, SecondaryFeatureCategory.DISCUSSION_CHECKPOINTS) + fun testDiscussionCheckpointsToDoE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1, syllabusBody = "this is the syllabus body") + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + val discussionWithCheckpointsTitle = "Test Discussion with Checkpoints" + val assignmentName = "Test Assignment with Checkpoints" + + Log.d(PREPARATION_TAG, "Convert dates to match with different formats in different screens (Calendar, Assignment Details)") + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val assignmentDetailsDisplayFormat = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.US) + val replyToTopicCalendar = getCustomDateCalendar(2) + val replyToEntryCalendar = getCustomDateCalendar(4) + val replyToTopicDueTime = dateFormat.format(replyToTopicCalendar.time) + val replyToEntryDueTime = dateFormat.format(replyToEntryCalendar.time) + val assignmentDetailsReplyToTopicDueDate = assignmentDetailsDisplayFormat.format(replyToTopicCalendar.time) + val assignmentDetailsReplyToEntryDueDate = assignmentDetailsDisplayFormat.format(replyToEntryCalendar.time) + val monthFormat = SimpleDateFormat("MMM", Locale.US) + val dayOfWeekFormat = SimpleDateFormat("EEE", Locale.US) + val dayFormat = SimpleDateFormat("d", Locale.US) + val dueTimeDisplayFormat = SimpleDateFormat("h:mm a", Locale.US) + val replyToTopicMonth = monthFormat.format(replyToTopicCalendar.time) + val replyToTopicDayOfWeek = dayOfWeekFormat.format(replyToTopicCalendar.time) + val replyToTopicDay = dayFormat.format(replyToTopicCalendar.time) + val replyToEntryMonth = monthFormat.format(replyToEntryCalendar.time) + val replyToEntryDayOfWeek = dayOfWeekFormat.format(replyToEntryCalendar.time) + val replyToEntryDay = dayFormat.format(replyToEntryCalendar.time) + val replyToTopicDueTimeDisplay = dueTimeDisplayFormat.format(replyToTopicCalendar.time) + val replyToEntryDueTimeDisplay = dueTimeDisplayFormat.format(replyToEntryCalendar.time) + + Log.d(PREPARATION_TAG, "Seed a discussion topic with checkpoints for '${course.name}' course.") + DiscussionTopicsApi.createDiscussionTopicWithCheckpoints(course.id, teacher.token, discussionWithCheckpointsTitle, assignmentName, replyToTopicDueTime, replyToEntryDueTime) + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Click on the 'To Do' bottom menu to navigate to the To Do list page.") + dashboardPage.clickTodoTab() + + Log.d(ASSERTION_TAG, "Assert that both checkpoints for the '$discussionWithCheckpointsTitle' discussion are displayed on the To Do List Page with the correct titles and due dates.") + retryWithIncreasingDelay(times = 10, maxDelay = 3000, catchBlock = { + Log.d(STEP_TAG, "If some of the checkpoints won't displayed at start, open the To-do Filter Page as they're probably 'out of range' of the filter interval.") + toDoListPage.clickFilterButton() + + Log.d(STEP_TAG, "Change the 'Show tasks until' filter to 'In Four Weeks' option and click on 'Done' button.") + toDoFilterPage.selectShowTasksUntilOption(R.string.todoFilterInFourWeeks) + toDoFilterPage.clickDone() + }) { + toDoListPage.assertDiscussionCheckpointItemDisplayed(discussionWithCheckpointsTitle, "Reply to topic") + toDoListPage.assertDiscussionCheckpointItemDisplayed(discussionWithCheckpointsTitle, "Additional replies (2)") + } + + toDoListPage.assertItemDateDay(discussionWithCheckpointsTitle, replyToTopicMonth, replyToTopicDayOfWeek, replyToTopicDay) + toDoListPage.assertItemDateDay(discussionWithCheckpointsTitle, replyToEntryMonth, replyToEntryDayOfWeek, replyToEntryDay) + toDoListPage.assertItemDueTime(discussionWithCheckpointsTitle, "Reply to topic", replyToTopicDueTimeDisplay) + toDoListPage.assertItemDueTime(discussionWithCheckpointsTitle, "Additional replies (2)", replyToEntryDueTimeDisplay) + + Log.d(STEP_TAG, "Click on the '$discussionWithCheckpointsTitle' discussion's 'Reply to topic' checkpoint To Do item to open it's details.") + toDoListPage.clickOnItem(discussionWithCheckpointsTitle, "Reply to topic") + + Log.d(ASSERTION_TAG, "Assert that the Assignment Details Page is displayed properly with the correct toolbar title and subtitle.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertDisplayToolbarSubtitle(course.name) + + Log.d(ASSERTION_TAG, "Assert that the checkpoints are displayed properly on the Assignment Details Page.") + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Reply to topic due", assignmentDetailsReplyToTopicDueDate) + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Additional replies (2) due", assignmentDetailsReplyToEntryDueDate) + + Log.d(STEP_TAG, "Navigate back to To Do list Page.") + Espresso.pressBack() + + Log.d(STEP_TAG, "Click on the '$discussionWithCheckpointsTitle' discussion's 'Additional replies' checkpoint To Do item to open it's details.") + toDoListPage.clickOnItem(discussionWithCheckpointsTitle, "Additional replies (2)") + + Log.d(ASSERTION_TAG, "Assert that the Assignment Details Page is displayed properly with the correct toolbar title and subtitle.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertDisplayToolbarSubtitle(course.name) + + Log.d(ASSERTION_TAG, "Assert that the checkpoints are displayed properly on the Assignment Details Page.") + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Reply to topic due", assignmentDetailsReplyToTopicDueDate) + assignmentDetailsPage.assertDiscussionCheckpointDetailsOnDetailsPage("Additional replies (2) due", assignmentDetailsReplyToEntryDueDate) + } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/offline/OfflineAnnouncementsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/offline/OfflineAnnouncementsE2ETest.kt index fa6e4c2775..adfa998225 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/offline/OfflineAnnouncementsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/offline/OfflineAnnouncementsE2ETest.kt @@ -16,8 +16,11 @@ */ package com.instructure.student.ui.e2e.classic.offline +import android.os.SystemClock import android.util.Log +import androidx.media3.ui.R import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory @@ -26,7 +29,8 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.OfflineE2E import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.DiscussionTopicsApi -import com.instructure.student.ui.utils.StudentTest +import com.instructure.espresso.getVideoPosition +import com.instructure.student.ui.utils.StudentComposeTest import com.instructure.student.ui.utils.extensions.seedData import com.instructure.student.ui.utils.extensions.tokenLogin import com.instructure.student.ui.utils.offline.OfflineTestUtils @@ -36,7 +40,7 @@ import org.junit.Test import java.lang.Thread.sleep @HiltAndroidTest -class OfflineAnnouncementsE2ETest : StudentTest() { +class OfflineAnnouncementsE2ETest : StudentComposeTest() { override fun displaysPageObjects() = Unit @@ -156,6 +160,106 @@ class OfflineAnnouncementsE2ETest : StudentTest() { announcementListPage.assertTopicDisplayed(announcement.title) } + @OfflineE2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ANNOUNCEMENTS, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) + fun testOfflineAnnouncementWithVideoAttachmentE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Read video file from assets.") + val videoFileName = "test_video.mp4" + val context = InstrumentationRegistry.getInstrumentation().context + val videoBytes = context.assets.open(videoFileName).use { it.readBytes() } + + Log.d(PREPARATION_TAG, "Seed an announcement with video attachment for '${course.name}' course.") + val announcementTitle = "Announcement with Video Attachment" + val announcementWithVideo = DiscussionTopicsApi.createAnnouncement(courseId = course.id, token = teacher.token, announcementTitle = announcementTitle, fileBytes = videoBytes, fileName = videoFileName) + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + + Log.d(STEP_TAG, "Expand '${course.name}' course.") + manageOfflineContentPage.expandCollapseItem(course.name) + + Log.d(STEP_TAG, "Select the 'Announcements' of '${course.name}' course for sync. Click on the 'Sync' button.") + manageOfflineContentPage.changeItemSelectionState("Announcements") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(ASSERTION_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + OfflineTestUtils.waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + refresh() + + Log.d(ASSERTION_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") + OfflineTestUtils.assertOfflineIndicator() + + Log.d(STEP_TAG, "Select '${course.name}' course and navigate to Announcements page.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectAnnouncements() + + Log.d(ASSERTION_TAG, "Assert that '${announcementWithVideo.title}' announcement is displayed.") + announcementListPage.assertTopicDisplayed(announcementWithVideo.title) + + Log.d(STEP_TAG, "Select '${announcementWithVideo.title}' announcement.") + announcementListPage.selectTopic(announcementWithVideo.title) + + Log.d(ASSERTION_TAG, "Assert that we are on the Discussion Details Page with the correct title.") + nativeDiscussionDetailsPage.assertTitleText(announcementWithVideo.title) + + Log.d(ASSERTION_TAG, "Assert that the attachment icon is displayed on the announcement details page.") + nativeDiscussionDetailsPage.assertMainAttachmentDisplayed() + + Log.d(STEP_TAG, "Click on the attachment icon to view the video attachment.") + nativeDiscussionDetailsPage.clickAttachmentIcon() + + Log.d(ASSERTION_TAG, "Wait for the video to start and assert that the video player controls are displayed.") + videoPlayerPage.waitForVideoToStart(device) + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the video.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current video position.") + val firstVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "First video position: $firstVideoPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + SystemClock.sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the video position again.") + val secondVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "Second video position: $secondVideoPositionText") + + Log.d(ASSERTION_TAG, "Assert that the video position has changed, confirming video is playing.") + assert(firstVideoPositionText != secondVideoPositionText) { + "Video position did not change. First: $firstVideoPositionText, Second: $secondVideoPositionText" + } + + Log.d(STEP_TAG, "Navigate back to the announcement details page.") + Espresso.pressBack() + + Log.d(ASSERTION_TAG, "Assert that we are back on the announcement details page.") + nativeDiscussionDetailsPage.assertTitleText(announcementWithVideo.title) + } + @After fun tearDown() { Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/offline/OfflineDiscussionsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/offline/OfflineDiscussionsE2ETest.kt index e867c439de..0242c70521 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/offline/OfflineDiscussionsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/classic/offline/OfflineDiscussionsE2ETest.kt @@ -18,6 +18,7 @@ package com.instructure.student.ui.e2e.classic.offline import android.util.Log import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory @@ -28,9 +29,13 @@ import com.instructure.canvas.espresso.checkToastText import com.instructure.canvas.espresso.pressBackButton import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.DiscussionTopicsApi +import com.instructure.dataseeding.api.FileFolderApi +import com.instructure.dataseeding.api.FileUploadsApi +import com.instructure.dataseeding.model.FileUploadType +import com.instructure.espresso.convertIso8601ToCanvasFormat import com.instructure.espresso.getDateInCanvasFormat import com.instructure.student.R -import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.StudentComposeTest import com.instructure.student.ui.utils.extensions.openOverflowMenu import com.instructure.student.ui.utils.extensions.seedData import com.instructure.student.ui.utils.extensions.tokenLogin @@ -40,7 +45,7 @@ import org.junit.After import org.junit.Test @HiltAndroidTest -class OfflineDiscussionsE2ETest : StudentTest() { +class OfflineDiscussionsE2ETest : StudentComposeTest() { override fun displaysPageObjects() = Unit @@ -189,6 +194,121 @@ class OfflineDiscussionsE2ETest : StudentTest() { checkToastText(R.string.notAvailableOffline, activityRule.activity) } + @OfflineE2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DISCUSSIONS, TestCategory.E2E, SecondaryFeatureCategory.OFFLINE_MODE) + fun testOfflineDiscussionCheckpointWithPdfAttachmentE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Get course root folder to upload the PDF file.") + val courseRootFolder = FileFolderApi.getCourseRootFolder(course.id, teacher.token) + + Log.d(PREPARATION_TAG, "Read PDF file from assets.") + val pdfFileName = "samplepdf.pdf" + val context = InstrumentationRegistry.getInstrumentation().context + val inputStream = context.assets.open(pdfFileName) + val pdfBytes = inputStream.readBytes() + inputStream.close() + + Log.d(PREPARATION_TAG, "Upload PDF file to course root folder using teacher token.") + val uploadedFile = FileUploadsApi.uploadFile(courseId = courseRootFolder.id, assignmentId = null, file = pdfBytes, fileName = pdfFileName, token = teacher.token, fileUploadType = FileUploadType.COURSE_FILE) + + Log.d(PREPARATION_TAG, "Seed a discussion topic with checkpoints and PDF attachment for '${course.name}' course.") + val discussionWithCheckpointsTitle = "Discussion with PDF Attachment" + val assignmentName = "Assignment with Checkpoints and PDF" + val replyToTopicDueDate = "2029-11-12T22:59:00Z" + val replyToEntryDueDate = "2029-11-19T22:59:00Z" + DiscussionTopicsApi.createDiscussionTopicWithCheckpoints(courseId = course.id, token = teacher.token, discussionTitle = discussionWithCheckpointsTitle, assignmentName = assignmentName, replyToTopicDueDate = replyToTopicDueDate, replyToEntryDueDate = replyToEntryDueDate, fileId = uploadedFile.id.toString()) + + val convertedReplyToTopicDueDate = "Due " + convertIso8601ToCanvasFormat("2029-11-12T22:59:00Z") + " 2:59 PM" + val convertedReplyToEntryDueDate = "Due " + convertIso8601ToCanvasFormat("2029-11-19T22:59:00Z") + " 2:59 PM" + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + + Log.d(STEP_TAG, "Expand '${course.name}' course.") + manageOfflineContentPage.expandCollapseItem(course.name) + + Log.d(STEP_TAG, "Select the 'Assignments' and 'Discussions' of '${course.name}' course for sync. Click on the 'Sync' button.") + manageOfflineContentPage.changeItemSelectionState("Assignments") + manageOfflineContentPage.changeItemSelectionState("Discussions") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(ASSERTION_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") + dashboardPage.assertCourseOfflineSyncIconVisible(course.name) + device.waitForIdle() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + OfflineTestUtils.waitForNetworkToGoOffline(device) + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + refresh() + + Log.d(ASSERTION_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") + OfflineTestUtils.assertOfflineIndicator() + + Log.d(STEP_TAG, "Select course: '${course.name}' and navigate to Assignments Page.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectAssignments() + + Log.d(ASSERTION_TAG, "Assert that the '${discussionWithCheckpointsTitle}' discussion is present along with 2 date info (for the 2 checkpoints).") + assignmentListPage.assertHasAssignmentWithCheckpoints(discussionWithCheckpointsTitle, dueAtString = convertedReplyToTopicDueDate, dueAtStringSecondCheckpoint = convertedReplyToEntryDueDate, expectedGrade = "-/15") + + Log.d(STEP_TAG, "Click on '$discussionWithCheckpointsTitle' assignment.") + assignmentListPage.clickAssignment(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that Assignment Details Page is displayed with correct title.") + assignmentDetailsPage.assertDisplayToolbarTitle() + assignmentDetailsPage.assertAssignmentTitle(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that attachment icon is displayed.") + assignmentDetailsPage.assertAttachmentIconDisplayed() + + Log.d(STEP_TAG, "Click on attachment icon to attempt to view the PDF attachment while offline.") + assignmentDetailsPage.clickAttachmentIcon() + + Log.d(ASSERTION_TAG, "Verify PDF viewer toolbar is displayed.") + assignmentDetailsPage.assertPdfViewerToolbarDisplayed() + + Log.d(STEP_TAG, "Navigate back from PDF viewer to Assignment Details page.") + Espresso.pressBack() + + Log.d(ASSERTION_TAG, "Assert that we're back on the Assignment Details page.") + assignmentDetailsPage.assertAssignmentTitle(discussionWithCheckpointsTitle) + + Log.d(STEP_TAG, "Click on 'View Discussion' button to navigate to the Discussion Details page.") + assignmentDetailsPage.clickSubmit() + + Log.d(ASSERTION_TAG, "Assert that the Discussion Details page is displayed with the correct title.") + nativeDiscussionDetailsPage.assertTitleText(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that the attachment icon is displayed on the Discussion Details page.") + nativeDiscussionDetailsPage.assertMainAttachmentDisplayed() + + Log.d(STEP_TAG, "Click on the attachment icon to view the PDF attachment from the Discussion Details page.") + nativeDiscussionDetailsPage.clickAttachmentIcon() + + Log.d(ASSERTION_TAG, "Verify PDF viewer toolbar is displayed.") + assignmentDetailsPage.assertPdfViewerToolbarDisplayed() + + Log.d(STEP_TAG, "Navigate back from PDF viewer to Discussion Details page.") + Espresso.pressBack() + + Log.d(ASSERTION_TAG, "Assert that we're back on the Discussion Details page.") + nativeDiscussionDetailsPage.assertTitleText(discussionWithCheckpointsTitle) + } + @After fun tearDown() { Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/AssignmentsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/AssignmentsE2ETest.kt index 160b018426..3d8408657c 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/AssignmentsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/AssignmentsE2ETest.kt @@ -20,6 +20,7 @@ import android.os.SystemClock.sleep import android.util.Log import android.view.KeyEvent import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority @@ -33,6 +34,7 @@ import com.instructure.canvas.espresso.pressBackButton import com.instructure.dataseeding.api.AssignmentGroupsApi import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.CoursesApi +import com.instructure.dataseeding.api.FileUploadsApi import com.instructure.dataseeding.api.SubmissionsApi import com.instructure.dataseeding.model.FileUploadType import com.instructure.dataseeding.model.GradingType @@ -41,6 +43,7 @@ import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 +import com.instructure.espresso.getVideoPosition import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.espresso.triggerWorkManagerJobs import com.instructure.pandautils.utils.toFormattedString @@ -55,6 +58,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runners.MethodSorters import java.util.Calendar +import androidx.media3.ui.R as Media3R @HiltAndroidTest @FixMethodOrder(MethodSorters.NAME_ASCENDING) @@ -873,6 +877,43 @@ class AssignmentsE2ETest: StudentComposeTest() { submissionDetailsPage.assertVideoCommentDisplayed() } + Log.d(STEP_TAG, "Click on the video comment to open it.") + submissionDetailsPage.clickVideoComment() + + Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") + videoPlayerPage.assertMediaCommentPreviewDisplayed() + + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the video.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current video position.") + val firstVideoPositionText = getVideoPosition(Media3R.id.exo_position) + Log.d(ASSERTION_TAG, "First video position: $firstVideoPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the video position again.") + val secondVideoPositionText = getVideoPosition(Media3R.id.exo_position) + Log.d(ASSERTION_TAG, "Second video position: $secondVideoPositionText") + + Log.d(ASSERTION_TAG, "Assert that the video position has changed, confirming video is playing.") + assert(firstVideoPositionText != secondVideoPositionText) { + "Video position did not change. First: $firstVideoPositionText, Second: $secondVideoPositionText" + } + + Log.d(STEP_TAG, "Navigate back to submission comments.") + Espresso.pressBack() + Log.d(STEP_TAG, "Send an audio comment.") submissionDetailsPage.addAndSendAudioComment() @@ -881,6 +922,44 @@ class AssignmentsE2ETest: StudentComposeTest() { triggerWorkManagerJobs("SubmissionWorker") submissionDetailsPage.assertAudioCommentDisplayed() } + + Log.d(STEP_TAG, "Click on the audio comment to open it.") + submissionDetailsPage.clickAudioComment() + + Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") + videoPlayerPage.assertMediaCommentPreviewDisplayed() + + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the audio.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current audio position.") + val firstAudioPositionText = getVideoPosition(Media3R.id.exo_position) + Log.d(ASSERTION_TAG, "First audio position: $firstAudioPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume audio playback, wait for audio to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the audio position again.") + val secondAudioPositionText = getVideoPosition(Media3R.id.exo_position) + Log.d(ASSERTION_TAG, "Second audio position: $secondAudioPositionText") + + Log.d(ASSERTION_TAG, "Assert that the audio position has changed, confirming audio is playing.") + assert(firstAudioPositionText != secondAudioPositionText) { + "Audio position did not change. First: $firstAudioPositionText, Second: $secondAudioPositionText" + } + + Log.d(STEP_TAG, "Navigate back to submission comments and assert that the audio comment still displayed.") + Espresso.pressBack() + submissionDetailsPage.assertAudioCommentDisplayed() } @E2E @@ -1280,6 +1359,101 @@ class AssignmentsE2ETest: StudentComposeTest() { dashboardPage.assertCourseGrade(course.name, "49.47%") } + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) + fun testVideoFileUploadSubmissionE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(teachers = 1, courses = 1, students = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Seeding 'File Upload' assignment for '${course.name}' course.") + val videoUploadAssignment = AssignmentsApi.createAssignment( + courseId = course.id, + teacherToken = teacher.token, + gradingType = GradingType.POINTS, + pointsPossible = 15.0, + dueAt = 1.days.fromNow.iso8601, + submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD) + ) + + Log.d(PREPARATION_TAG, "Upload the mp4 file from assets as an assignment submission file.") + val videoFileName = "test_video.mp4" + val videoFileBytes = InstrumentationRegistry.getInstrumentation().context.assets.open(videoFileName).readBytes() + val uploadInfo = FileUploadsApi.uploadFile( + courseId = course.id, + assignmentId = videoUploadAssignment.id, + file = videoFileBytes, + fileName = videoFileName, + token = student.token, + fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION + ) + + Log.d(PREPARATION_TAG, "Submit '${videoUploadAssignment.name}' assignment for '${student.name}' student with the uploaded mp4 file.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, videoUploadAssignment.id, SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(uploadInfo.id)) + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select '${course.name}' course and navigate to its Assignments Page.") + dashboardPage.selectCourse(course) + courseBrowserPage.selectAssignments() + + Log.d(ASSERTION_TAG, "Assert that '${videoUploadAssignment.name}' assignment is displayed.") + assignmentListPage.assertHasAssignment(videoUploadAssignment) + + Log.d(STEP_TAG, "Click on '${videoUploadAssignment.name}' assignment.") + assignmentListPage.clickAssignment(videoUploadAssignment) + + Log.d(ASSERTION_TAG, "Assert that the assignment has been submitted.") + assignmentDetailsPage.assertAssignmentSubmittedStatus() + + Log.d(STEP_TAG, "Navigate to submission details and open Files Tab.") + assignmentDetailsPage.goToSubmissionDetails() + submissionDetailsPage.openFiles() + + Log.d(ASSERTION_TAG, "Assert that '$videoFileName' file has been displayed in the Files tab.") + submissionDetailsPage.assertFileDisplayed(videoFileName) + + Log.d(STEP_TAG, "Collapse the sliding panel to show the video player.") + submissionDetailsPage.collapseSlidingPanel() + + Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") + videoPlayerPage.assertMediaCommentPreviewDisplayed() + + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the video.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current video position.") + val firstVideoPositionText = getVideoPosition(Media3R.id.exo_position) + Log.d(ASSERTION_TAG, "First video position: $firstVideoPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the video position again.") + val secondVideoPositionText = getVideoPosition(Media3R.id.exo_position) + Log.d(ASSERTION_TAG, "Second video position: $secondVideoPositionText") + + Log.d(ASSERTION_TAG, "Assert that the video position has changed, confirming video is playing.") + assert(firstVideoPositionText != secondVideoPositionText) { + "Video position did not change. First: $firstVideoPositionText, Second: $secondVideoPositionText" + } + } + @E2E @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.E2E) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/CalendarE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/CalendarE2ETest.kt index a7ede1d4cb..3413e11d85 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/CalendarE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/CalendarE2ETest.kt @@ -154,12 +154,12 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the page title is 'Calendar'.") calendarScreenPage.assertCalendarPageTitle() - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo Title" val testTodoDescription = "Details of ToDo" @@ -169,26 +169,26 @@ class CalendarE2ETest : StudentComposeTest() { calendarToDoCreateUpdatePage.clickSave() val currentDate = getDateInCanvasCalendarFormat() - Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously created To Do item is displayed with the corresponding title, context and date.") - calendarScreenPage.assertItemDetails(testTodoTitle, "To Do", "$currentDate at 12:00 PM") + Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously created To-do item is displayed with the corresponding title, context and date.") + calendarScreenPage.assertItemDetails(testTodoTitle, "To-do", "$currentDate at 12:00 PM") - Log.d(STEP_TAG, "Clicks on the '$testTodoTitle' To Do item.") + Log.d(STEP_TAG, "Clicks on the '$testTodoTitle' To-do item.") calendarScreenPage.clickOnItem(testTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle', the context is 'To Do', the date is the current day with 12:00 PM time and the description is '$testTodoDescription'.") + Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle', the context is 'To-do', the date is the current day with 12:00 PM time and the description is '$testTodoDescription'.") calendarToDoDetailsPage.assertTitle(testTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") calendarToDoDetailsPage.assertDate("$currentDate at 12:00 PM") calendarToDoDetailsPage.assertDescription(testTodoDescription) - Log.d(STEP_TAG, "Click on the 'Edit To Do' within the toolbar more menu and confirm the editing.") + Log.d(STEP_TAG, "Click on the 'Edit To-do' within the toolbar more menu and confirm the editing.") calendarToDoDetailsPage.clickToolbarMenu() calendarToDoDetailsPage.clickEditMenu() - Log.d(ASSERTION_TAG, "Assert that the page title is 'Edit To Do' as we are editing an existing To Do item.") - calendarToDoCreateUpdatePage.assertPageTitle("Edit To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'Edit To-do' as we are editing an existing To-do item.") + calendarToDoCreateUpdatePage.assertPageTitle("Edit To-do") - Log.d(ASSERTION_TAG, "Assert that the 'original' To Do Title and details has been filled into the input fields as we on the edit screen.") + Log.d(ASSERTION_TAG, "Assert that the 'original' To-do Title and details has been filled into the input fields as we on the edit screen.") calendarToDoCreateUpdatePage.assertTodoTitle(testTodoTitle) calendarToDoCreateUpdatePage.assertDetails(testTodoDescription) @@ -199,19 +199,19 @@ class CalendarE2ETest : StudentComposeTest() { calendarToDoCreateUpdatePage.typeDetails(modifiedTestTodoDescription) calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously modified To Do item is displayed with the corresponding title, context and with the same date as we haven't changed it.") - calendarScreenPage.assertItemDetails(modifiedTestTodoTitle, "To Do", "$currentDate at 12:00 PM") + Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously modified To-do item is displayed with the corresponding title, context and with the same date as we haven't changed it.") + calendarScreenPage.assertItemDetails(modifiedTestTodoTitle, "To-do", "$currentDate at 12:00 PM") - Log.d(STEP_TAG, "Clicks on the '$modifiedTestTodoTitle' To Do item.") + Log.d(STEP_TAG, "Clicks on the '$modifiedTestTodoTitle' To-do item.") calendarScreenPage.clickOnItem(modifiedTestTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the To Do title is '$modifiedTestTodoTitle', the page title is 'To Do', the date remained current day with 12:00 PM time (as we haven't modified it) and the description is '$modifiedTestTodoDescription'.") + Log.d(ASSERTION_TAG, "Assert that the To-do title is '$modifiedTestTodoTitle', the page title is 'To-do', the date remained current day with 12:00 PM time (as we haven't modified it) and the description is '$modifiedTestTodoDescription'.") calendarToDoDetailsPage.assertTitle(modifiedTestTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") calendarToDoDetailsPage.assertDate("$currentDate at 12:00 PM") calendarToDoDetailsPage.assertDescription(modifiedTestTodoDescription) - Log.d(STEP_TAG, "Click on the 'Delete To Do' within the toolbar more menu and confirm the deletion.") + Log.d(STEP_TAG, "Click on the 'Delete To-do' within the toolbar more menu and confirm the deletion.") calendarToDoDetailsPage.clickToolbarMenu() calendarToDoDetailsPage.clickDeleteMenu() calendarToDoDetailsPage.confirmDeletion() @@ -219,7 +219,7 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the deleted item does not exist anymore on the Calendar Screen Page.") calendarScreenPage.assertItemNotExist(modifiedTestTodoTitle) - Log.d(ASSERTION_TAG, "Assert that after the deletion the empty view will be displayed since we don't have any To Do items on the current day.") + Log.d(ASSERTION_TAG, "Assert that after the deletion the empty view will be displayed since we don't have any To-do items on the current day.") calendarScreenPage.assertEmptyView() } @@ -269,12 +269,12 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the event is displayed with the corresponding details (title, context name, date, status) on the page.") calendarScreenPage.assertItemDetails(newEventTitle, student.name, currentDate) - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo Title" val testTodoDescription = "Details of ToDo" @@ -290,7 +290,7 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Click on the 'Save' button.") calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To Do item is displayed because we created it to this particular day. Assert that '$newEventTitle' calendar event is not displayed because it's created for today.") + Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To-do item is displayed because we created it to this particular day. Assert that '$newEventTitle' calendar event is not displayed because it's created for today.") calendarScreenPage.assertItemDisplayed(testTodoTitle) calendarScreenPage.assertItemNotExist(newEventTitle) @@ -301,7 +301,7 @@ class CalendarE2ETest : StudentComposeTest() { calendarFilterPage.clickOnFilterItem(course.name) calendarFilterPage.closeFilterPage() - Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To Do item is not displayed because we filtered out from the calendar. " + + Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To-do item is not displayed because we filtered out from the calendar. " + "Assert that the empty view is displayed because there are no items for today.") calendarScreenPage.assertItemNotExist(testTodoTitle) calendarScreenPage.assertEmptyView() @@ -588,12 +588,12 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the page title is 'Calendar'.") calendarScreenPage.assertCalendarPageTitle() - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo With Reminder" val testTodoDescription = "Details of ToDo" @@ -608,15 +608,15 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Click on the 'Save' button.") calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) - Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To Do item.") + Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To-do item.") calendarScreenPage.clickOnItem(testTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To Do'.") + Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To-do'.") calendarToDoDetailsPage.assertTitle(testTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") Log.d(ASSERTION_TAG, "Assert that the reminder section is displayed.") calendarToDoDetailsPage.assertReminderSectionDisplayed() @@ -630,7 +630,7 @@ class CalendarE2ETest : StudentComposeTest() { calendarToDoDetailsPage.selectDate(reminderDateOneHour) calendarToDoDetailsPage.selectTime(reminderDateOneHour) - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneHour.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -660,7 +660,7 @@ class CalendarE2ETest : StudentComposeTest() { calendarToDoDetailsPage.selectDate(reminderDateOneWeek) calendarToDoDetailsPage.selectTime(reminderDateOneWeek) - Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To Do which is due in 2 days).") + Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To-do which is due in 2 days).") calendarToDoDetailsPage.assertReminderNotDisplayedWithText(reminderDateOneWeek.time.toFormattedString()) checkToastText(R.string.reminderInPast, activityRule.activity) futureDate.apply { add(Calendar.WEEK_OF_YEAR, 1) } @@ -674,7 +674,7 @@ class CalendarE2ETest : StudentComposeTest() { calendarToDoDetailsPage.selectDate(reminderDateOneDay) calendarToDoDetailsPage.selectTime(reminderDateOneDay) - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneDay.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -692,7 +692,7 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Navigate back to Calendar Screen Page.") Espresso.pressBack() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) } @@ -719,12 +719,12 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(ASSERTION_TAG, "Assert that the page title is 'Calendar'.") calendarScreenPage.assertCalendarPageTitle() - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo With Reminder" val testTodoDescription = "Details of ToDo" @@ -739,15 +739,15 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Click on the 'Save' button.") calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) - Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To Do item.") + Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To-do item.") calendarScreenPage.clickOnItem(testTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To Do'.") + Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To-do'.") calendarToDoDetailsPage.assertTitle(testTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") Log.d(ASSERTION_TAG, "Assert that the reminder section is displayed.") calendarToDoDetailsPage.assertReminderSectionDisplayed() @@ -759,7 +759,7 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Select '1 Hour Before'.") calendarToDoDetailsPage.clickBeforeReminderOption("1 Hour Before") - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneHour.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -785,7 +785,7 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Select '1 Week Before'.") calendarToDoDetailsPage.clickBeforeReminderOption("1 Week Before") - Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To Do which is due in 2 days).") + Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To-do which is due in 2 days).") calendarToDoDetailsPage.assertReminderNotDisplayedWithText(reminderDateOneWeek.time.toFormattedString()) checkToastText(R.string.reminderInPast, activityRule.activity) futureDate.apply { add(Calendar.WEEK_OF_YEAR, 1) } @@ -797,7 +797,7 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Select '1 Day Before'.") calendarToDoDetailsPage.clickBeforeReminderOption("1 Day Before") - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneDay.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -813,7 +813,7 @@ class CalendarE2ETest : StudentComposeTest() { Log.d(STEP_TAG, "Navigate back to Calendar Screen Page.") Espresso.pressBack() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/InboxE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/InboxE2ETest.kt index 01918ff762..1f9626a536 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/InboxE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/InboxE2ETest.kt @@ -14,7 +14,6 @@ * limitations under the License. * */ - package com.instructure.student.ui.e2e.compose import android.os.Environment @@ -718,27 +717,26 @@ class InboxE2ETest: StudentComposeTest() { Log.d(STEP_TAG, "Click on the attachment to verify it can be opened.") inboxDetailsPage.clickAttachment(videoFileName) - Log.d(ASSERTION_TAG, "Wait for video to load and assert that the media play button is visible.") - inboxDetailsPage.assertPlayButtonDisplayed() + Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") + videoPlayerPage.assertMediaCommentPreviewDisplayed() - Log.d(STEP_TAG, "Click the play button to start the video and on the screen to show media controls.") - inboxDetailsPage.clickPlayButton() - inboxDetailsPage.clickScreenCenterToShowControls(device) + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") - inboxDetailsPage.assertPlayPauseButtonDisplayed() + videoPlayerPage.assertPlayPauseButtonDisplayed() Log.d(STEP_TAG, "Click play/pause button to pause the video.") - inboxDetailsPage.clickPlayPauseButton() + videoPlayerPage.clickPlayPauseButton() Log.d(STEP_TAG, "Get the current video position.") val firstPositionText = getVideoPosition(R.id.exo_position) - Log.d(ASSERTION_TAG, "First position: $firstPositionText") Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") - inboxDetailsPage.clickPlayPauseButton() + videoPlayerPage.clickPlayPauseButton() sleep(2000) - inboxDetailsPage.clickPlayPauseButton() + videoPlayerPage.clickPlayPauseButton() Log.d(STEP_TAG, "Get the video position again.") val secondPositionText = getVideoPosition(R.id.exo_position) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt index beafbbc822..0253e07c23 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt @@ -19,7 +19,6 @@ import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData -import com.instructure.student.R import com.instructure.student.ui.utils.StudentTest import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @@ -29,19 +28,6 @@ class LoginInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit // Not used for interaction tests - @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.LOGIN, TestCategory.INTERACTION) - fun testLogin_canFindSchool() { - loginLandingPage.clickFindMySchoolButton() - loginFindSchoolPage.assertPageObjects() - - if(isTabletDevice()) loginFindSchoolPage.assertHintText(R.string.schoolInstructureCom) - else loginFindSchoolPage.assertHintText(R.string.loginHint) - - loginFindSchoolPage.enterDomain("harvest") - loginFindSchoolPage.assertSchoolSearchResults("City Harvest Church (Singapore)") - } - @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.LOGIN, TestCategory.INTERACTION) fun testLogin_qrTutorialPageLoads() { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt index bcddb0dca6..af5bc3801f 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ScheduleInteractionTest.kt @@ -130,7 +130,7 @@ class ScheduleInteractionTest : StudentComposeTest() { goToScheduleTab(data) schedulePage.scrollToPosition(8) - schedulePage.assertCourseHeaderDisplayed(schedulePage.getStringFromResource(R.string.schedule_todo_title)) + schedulePage.assertCourseHeaderDisplayed(schedulePage.getStringFromResource(R.string.schedule_todo_title_new)) schedulePage.assertScheduleItemDisplayed(todo.plannable.title) schedulePage.assertScheduleItemDisplayed(todo2.plannable.title) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/LeftSideNavigationDrawerPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/LeftSideNavigationDrawerPage.kt index c4bf6517f3..70745237d8 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/LeftSideNavigationDrawerPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/LeftSideNavigationDrawerPage.kt @@ -75,7 +75,8 @@ class LeftSideNavigationDrawerPage : BasePage() { fun logout() { onView(hamburgerButtonMatcher).click() - logoutButton.scrollTo().click() + onView(withId(R.id.navigationDrawer)).swipeUp() // Swipe up to ensure the logout button is visible (since edge-to-edge the android navigation bar overlaps with Logout button). + logoutButton.click() onViewWithText(android.R.string.ok).click() // It can potentially take a long time for the sign-out to take effect, especially on // slow FTL devices. So let's pause for a bit until we see the canvas logo. diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/StudentAssignmentDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/StudentAssignmentDetailsPage.kt index dba4b5084b..a84e6f8708 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/StudentAssignmentDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/StudentAssignmentDetailsPage.kt @@ -69,6 +69,7 @@ class StudentAssignmentDetailsPage(moduleItemInteractions: ModuleItemInteraction fun assertDiscussionCheckpointDetailsOnDetailsPage(checkpointText: String, dueAt: String) { + composeTestRule.waitForIdle() try { composeTestRule.onNode(hasText(dueAt) and hasAnyAncestor(hasTestTag("dueDateColumn-$checkpointText") and hasAnyDescendant(hasTestTag("dueDateHeaderText-$checkpointText")))).assertIsDisplayed() } catch (e: AssertionError) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/SubmissionDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/SubmissionDetailsPage.kt index 48c7f51907..ca13645290 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/SubmissionDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/SubmissionDetailsPage.kt @@ -16,7 +16,13 @@ */ package com.instructure.student.ui.pages.classic +import android.view.View import androidx.test.espresso.Espresso +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.GeneralLocation +import androidx.test.espresso.action.GeneralSwipeAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Swipe import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches @@ -157,6 +163,13 @@ open class SubmissionDetailsPage : BasePage(R.id.submissionDetails) { submissionCommentsRenderPage.scrollAndAssertDisplayed(commentMatcher) } + /** + * Click on the video comment to open it + */ + fun clickVideoComment() { + onView(allOf(withId(R.id.attachmentNameTextView), containsTextCaseInsensitive("video"))).click() + } + /** * Assert that the comment stream contains an audio comment */ @@ -169,6 +182,13 @@ open class SubmissionDetailsPage : BasePage(R.id.submissionDetails) { submissionCommentsRenderPage.scrollAndAssertDisplayed(commentMatcher) } + /** + * Click on the audio comment to open it + */ + fun clickAudioComment() { + onView(allOf(withId(R.id.attachmentNameTextView), containsTextCaseInsensitive("audio"))).click() + } + /** * Assert that a comment is displayed * [fileName] is the name of the attached file @@ -220,6 +240,25 @@ open class SubmissionDetailsPage : BasePage(R.id.submissionDetails) { onView(matcher).assertDisplayed() } + /* Grabs the current coordinates of the center of drawerTabLayout */ + private val tabLayoutCoordinates = CoordinatesProvider { view -> + val tabs = view.findViewById(R.id.drawerTabLayout) + val xy = IntArray(2).apply { tabs.getLocationOnScreen(this) } + val x = xy[0] + (tabs.width / 2f) + val y = xy[1] + (tabs.height / 2f) + floatArrayOf(x, y) + } + + fun swipeDrawerTo(location: GeneralLocation) { + onView(withId(R.id.slidingUpPanelLayout)).perform( + GeneralSwipeAction(Swipe.FAST, tabLayoutCoordinates, location, Press.FINGER) + ) + } + + fun collapseSlidingPanel() { + swipeDrawerTo(GeneralLocation.BOTTOM_CENTER) + } + fun addAndSendComment(comment: String) { submissionCommentsRenderPage.addAndSendComment(comment) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/offline/NativeDiscussionDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/offline/NativeDiscussionDetailsPage.kt index 073580ce53..ee17271db8 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/offline/NativeDiscussionDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/classic/offline/NativeDiscussionDetailsPage.kt @@ -278,7 +278,11 @@ class NativeDiscussionDetailsPage(val moduleItemInteractions: ModuleItemInteract } fun assertMainAttachmentDisplayed() { - onView(withId(R.id.attachmentIcon)).assertDisplayed() + onView(withId(R.id.attachmentIcon) + withAncestor(R.id.discussionDetailsPage)).assertDisplayed() + } + + fun clickAttachmentIcon() { + onView(withId(R.id.attachmentIcon) + withAncestor(R.id.discussionDetailsPage)).click() } /** diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/renderpages/SubmissionCommentsRenderPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/renderpages/SubmissionCommentsRenderPage.kt index c6ece490cc..bca33cea82 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/renderpages/SubmissionCommentsRenderPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/renderpages/SubmissionCommentsRenderPage.kt @@ -153,7 +153,7 @@ class SubmissionCommentsRenderPage: BasePage(R.id.submissionCommentsPage) { clickOnAddAttachmentButton() onView(withId(R.id.videoComment)).click() onView(allOf(withId(R.id.startRecordingButton), isDisplayed())).click() - sleep(3000) + sleep(4000) onView(allOf(withId(R.id.endRecordingButton), isDisplayed())).click() onView(allOf(withId(R.id.sendButton), isDisplayed())).click() } @@ -162,7 +162,7 @@ class SubmissionCommentsRenderPage: BasePage(R.id.submissionCommentsPage) { clickOnAddAttachmentButton() onView(withId(R.id.audioComment)).click() onView(allOf(withId(R.id.recordAudioButton), isDisplayed())).click() - sleep(3000) + sleep(4000) onView(allOf(withId(R.id.stopButton), isDisplayed())).click() onView(allOf(withId(R.id.sendAudioButton), isDisplayed())).click() } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/renderpages/SubmissionDetailsRenderPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/renderpages/SubmissionDetailsRenderPage.kt index b07b53c8f7..d65406e6bf 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/renderpages/SubmissionDetailsRenderPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/renderpages/SubmissionDetailsRenderPage.kt @@ -18,11 +18,6 @@ package com.instructure.student.ui.rendertests.renderpages import android.view.View import androidx.test.espresso.Espresso.onData -import androidx.test.espresso.action.CoordinatesProvider -import androidx.test.espresso.action.GeneralLocation -import androidx.test.espresso.action.GeneralSwipeAction -import androidx.test.espresso.action.Press -import androidx.test.espresso.action.Swipe import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.BoundedMatcher import androidx.test.espresso.matcher.ViewMatchers.isDisplayed @@ -59,17 +54,6 @@ class SubmissionDetailsRenderPage : SubmissionDetailsPage() { val slidingPanel by OnViewWithId(R.id.slidingUpPanelLayout) val versionSpinner by OnViewWithId(R.id.submissionVersionsSpinner) - /* Grabs the current coordinates of the center of drawerTabLayout */ - private val tabLayoutCoordinates = object : CoordinatesProvider { - override fun calculateCoordinates(view: View): FloatArray { - val tabs = view.findViewById(R.id.drawerTabLayout) - val xy = IntArray(2).apply { tabs.getLocationOnScreen(this) } - val x = xy[0] + (tabs.width / 2f) - val y = xy[1] + (tabs.height / 2f) - return floatArrayOf(x, y) - } - } - fun assertDisplaysToolbarTitle(text: String) { onViewWithText(text).assertDisplayed() } @@ -120,10 +104,6 @@ class SubmissionDetailsRenderPage : SubmissionDetailsPage() { onView(allOf(withAncestor(R.id.drawerTabLayout), withText(name))).click() } - fun swipeDrawerTo(location: GeneralLocation) { - slidingPanel.perform(GeneralSwipeAction(Swipe.FAST, tabLayoutCoordinates, location, Press.FINGER)) - } - fun assertSpinnerMatchesText(text: String) { onView(allOf(withId(R.id.attemptDate), withText(text))).assertDisplayed() } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt index 4926c93072..50e9c884b0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentComposeTest.kt @@ -20,6 +20,7 @@ package com.instructure.student.ui.utils import androidx.compose.ui.test.junit4.createAndroidComposeRule import com.instructure.canvas.espresso.common.pages.AssignmentReminderPage +import com.instructure.canvas.espresso.common.pages.VideoPlayerPage import com.instructure.canvas.espresso.common.pages.compose.AssignmentListPage import com.instructure.canvas.espresso.common.pages.compose.CalendarEventCreateEditPage import com.instructure.canvas.espresso.common.pages.compose.CalendarEventDetailsPage @@ -69,6 +70,7 @@ abstract class StudentComposeTest : StudentTest() { val inboxSignatureSettingsPage = InboxSignatureSettingsPage(composeTestRule) val toDoListPage = ToDoListPage(composeTestRule) val toDoFilterPage = ToDoFilterPage(composeTestRule) + val videoPlayerPage = VideoPlayerPage() val assignmentDetailsPage = StudentAssignmentDetailsPage( ModuleItemInteractions( R.id.moduleName, diff --git a/apps/student/src/main/AndroidManifest.xml b/apps/student/src/main/AndroidManifest.xml index af986c7a9d..eb4b93f739 100644 --- a/apps/student/src/main/AndroidManifest.xml +++ b/apps/student/src/main/AndroidManifest.xml @@ -75,6 +75,10 @@ tools:replace="android:supportsRtl" tools:overrideLibrary="com.instructure.canvasapi"> + + @@ -314,7 +318,7 @@ + android:label="@string/todoWidgetTitleLongNew"> diff --git a/apps/student/src/main/java/com/instructure/student/AnnotationComments/AnnotationCommentListFragment.kt b/apps/student/src/main/java/com/instructure/student/AnnotationComments/AnnotationCommentListFragment.kt index e9eaaac3c0..bc92d70101 100644 --- a/apps/student/src/main/java/com/instructure/student/AnnotationComments/AnnotationCommentListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/AnnotationComments/AnnotationCommentListFragment.kt @@ -15,6 +15,7 @@ * */ package com.instructure.student.AnnotationComments +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -41,7 +42,23 @@ import com.instructure.interactions.router.Route import com.instructure.pandautils.analytics.SCREEN_VIEW_ANNOTATION_COMMENT_LIST import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.BooleanArg +import com.instructure.pandautils.utils.LongArg +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.ParcelableArrayListArg +import com.instructure.pandautils.utils.StringArg +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyImeAndSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.hideKeyboard +import com.instructure.pandautils.utils.onClickWithRequireNetwork +import com.instructure.pandautils.utils.onTextChanged +import com.instructure.pandautils.utils.setGone +import com.instructure.pandautils.utils.setInvisible +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.setupAsCloseButton +import com.instructure.pandautils.utils.withArgs import com.instructure.student.R import com.instructure.student.databinding.FragmentAnnotationCommentListBinding import com.instructure.student.fragment.ParentFragment @@ -49,7 +66,7 @@ import com.instructure.student.mobius.assignmentDetails.submissionDetails.conten import kotlinx.coroutines.Job import okhttp3.ResponseBody import org.greenrobot.eventbus.EventBus -import java.util.* +import java.util.Locale @ScreenView(SCREEN_VIEW_ANNOTATION_COMMENT_LIST) class AnnotationCommentListFragment : ParentFragment() { @@ -75,6 +92,7 @@ class AnnotationCommentListFragment : ParentFragment() { with (binding) { toolbar.title = title() toolbar.setupAsCloseButton(this@AnnotationCommentListFragment) + toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarLight(requireActivity(), toolbar) } } @@ -111,6 +129,7 @@ class AnnotationCommentListFragment : ParentFragment() { configureRecyclerView() applyTheme() setupCommentInput() + setupWindowInsets() if(recyclerAdapter?.size() == 0) { recyclerAdapter?.addAll(annotations) @@ -128,6 +147,11 @@ class AnnotationCommentListFragment : ParentFragment() { assigneeId)) } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ViewStyler.setStatusBarLightDelayed(requireActivity()) + } + fun configureRecyclerView() { val layoutManager = LinearLayoutManager(requireContext()) layoutManager.orientation = RecyclerView.VERTICAL @@ -156,6 +180,12 @@ class AnnotationCommentListFragment : ParentFragment() { } } + private fun setupWindowInsets() = with(binding) { + toolbar.applyTopSystemBarInsets() + annotationCommentsRecyclerView.applyImeAndSystemBarInsets() + commentInputContainer.applyImeAndSystemBarInsets() + } + private fun showSendingStatus() = with(binding) { sendCommentButton.setInvisible() sendingProgressBar.setVisible() diff --git a/apps/student/src/main/java/com/instructure/student/activity/CandroidPSPDFActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/CandroidPSPDFActivity.kt index f29384eb1f..12c30c2567 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/CandroidPSPDFActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/CandroidPSPDFActivity.kt @@ -49,6 +49,8 @@ import com.pspdfkit.ui.toolbar.ContextualToolbar import com.pspdfkit.ui.toolbar.ContextualToolbarMenuItem import com.pspdfkit.ui.toolbar.ToolbarCoordinatorLayout import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import java.io.File import java.util.EnumSet import java.util.Locale @@ -100,13 +102,15 @@ class CandroidPSPDFActivity : PdfActivity(), ToolbarCoordinatorLayout.OnContextu override fun onDestroy() { val path = filesDir.path + intent.data?.path?.replace("/files", "") document?.let { - val annotations = it.annotationProvider.getAllAnnotationsOfType( - EnumSet.allOf(AnnotationType::class.java) - ) - if (annotations.isEmpty() && networkStateProvider.isOnline()) { - val file = File(path) - if (file.exists()) { - file.delete() + GlobalScope.launch { + val annotations = it.annotationProvider.getAllAnnotationsOfType( + EnumSet.allOf(AnnotationType::class.java) + ) + if (annotations.isEmpty() && networkStateProvider.isOnline()) { + val file = File(path) + if (file.exists()) { + file.delete() + } } } } diff --git a/apps/student/src/main/java/com/instructure/student/activity/InternalWebViewActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/InternalWebViewActivity.kt index f149a51e60..3f79362663 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/InternalWebViewActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/InternalWebViewActivity.kt @@ -20,10 +20,13 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.os.Bundle +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.CanvasContext.Companion.emptyCourseContext import com.instructure.pandautils.activities.BaseActionBarActivity import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.toast @@ -35,7 +38,9 @@ import com.instructure.student.fragment.InternalWebviewFragment.Companion.newIns class InternalWebViewActivity : BaseActionBarActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + EdgeToEdgeHelper.enableEdgeToEdge(this) toolbar?.let { ViewStyler.themeToolbarLight(this, it) } + setupWindowInsets() if (savedInstanceState == null) { val bundle = intent.getBundleExtra(Const.EXTRAS) bundle?.getString(Const.ACTION_BAR_TITLE)?.let { toolbar?.title = it } @@ -75,6 +80,70 @@ class InternalWebViewActivity : BaseActionBarActivity() { if (fragment?.handleBackPressed() != true) super.onBackPressed() } + private fun setupWindowInsets() { + val container = findViewById(R.id.container) + + // Setup toolbar insets - handle top (status bar) + horizontal (nav bars + display cutout) in one listener + toolbar?.let { toolbar -> + ViewCompat.setOnApplyWindowInsetsListener(toolbar) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + // In landscape, apply status bar at top and nav bars + display cutout horizontally + val leftPadding = maxOf(systemBars.left, displayCutout.left) + val rightPadding = maxOf(systemBars.right, displayCutout.right) + + view.setPadding( + leftPadding, + systemBars.top, + rightPadding, + 0 + ) + } else { + // In portrait, apply status bar at top and display cutout horizontally + view.setPadding( + displayCutout.left, + systemBars.top, + displayCutout.right, + 0 + ) + } + insets + } + } + + // Setup container insets + ViewCompat.setOnApplyWindowInsetsListener(container) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val isLandscape = resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + // In landscape, apply navigation bars + display cutout horizontally and nav bar at bottom + val leftPadding = maxOf(navigationBars.left, displayCutout.left) + val rightPadding = maxOf(navigationBars.right, displayCutout.right) + + view.setPadding( + leftPadding, + 0, + rightPadding, + navigationBars.bottom + ) + } else { + // In portrait, only apply display cutout insets horizontally + view.setPadding( + displayCutout.left, + 0, + displayCutout.right, + 0 + ) + } + insets + } + } + private fun setActionBarStatusBarColor(color: Int) { val contentColor = resources?.getColor(R.color.textLightest) ?: Color.WHITE toolbar?.let { diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt index 4174d09dbb..8cfc85194f 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt @@ -31,6 +31,7 @@ import android.view.ViewGroup import android.view.accessibility.AccessibilityNodeInfo import android.widget.CompoundButton import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -41,8 +42,13 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.Toolbar import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.graphics.Insets import androidx.core.view.GravityCompat import androidx.core.view.MenuItemCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope @@ -103,7 +109,9 @@ import com.instructure.pandautils.room.offline.OfflineDatabase import com.instructure.pandautils.typeface.TypefaceBehavior import com.instructure.pandautils.update.UpdateManager import com.instructure.pandautils.utils.ActivityResult +import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.pandautils.utils.LocaleUtils import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.OnActivityResults @@ -126,6 +134,7 @@ import com.instructure.pandautils.utils.postSticky import com.instructure.pandautils.utils.setGone import com.instructure.pandautils.utils.setVisible import com.instructure.pandautils.utils.setupAsBackButton +import com.instructure.pandautils.utils.toPx import com.instructure.pandautils.utils.toast import com.instructure.student.R import com.instructure.student.databinding.ActivityNavigationBinding @@ -137,12 +146,13 @@ import com.instructure.student.events.CourseColorOverlayToggledEvent import com.instructure.student.events.ShowConfettiEvent import com.instructure.student.events.ShowGradesToggledEvent import com.instructure.student.events.UserUpdatedEvent +import com.instructure.student.features.dashboard.compose.NewDashboardFragment import com.instructure.student.features.files.list.FileListFragment import com.instructure.student.features.modules.progression.CourseModuleProgressionFragment import com.instructure.student.features.navigation.NavigationRepository import com.instructure.student.fragment.BookmarksFragment -import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.NotificationListFragment +import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.mobius.assignmentDetails.submission.picker.PickerSubmissionUploadEffectHandler import com.instructure.student.mobius.assignmentDetails.submissionDetails.content.emptySubmission.ui.SubmissionDetailsEmptyContentFragment @@ -328,8 +338,10 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. only one fragment on the backstack, which commonly occurs with non-root fragments when routing from external sources. */ val visible = isBottomNavFragment(it) || supportFragmentManager.backStackEntryCount <= 1 - binding.bottomBar.setVisible(visible) + binding.bottomBarContainer.setVisible(visible) binding.bottomBarDivider.setVisible(visible) + // Request insets reapplication when bottom bar visibility changes + ViewCompat.requestApplyInsets(binding.bottomBarContainer) } } @@ -337,6 +349,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. super.onResume() applyCurrentFragmentTheme() webViewAuthenticator.authenticateWebViews(lifecycleScope, this) + updateStatusBarAppearanceForDrawer() } private fun checkAppUpdates() { @@ -362,12 +375,16 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + EdgeToEdgeHelper.enableEdgeToEdge(this) RouteMatcher.offlineDb = offlineDatabase RouteMatcher.networkStateProvider = networkStateProvider RouteMatcher.enabledTabs = enabledCourseTabs navigationDrawerBinding = NavigationDrawerBinding.bind(binding.root) canvasLoadingBinding = LoadingCanvasViewBinding.bind(binding.root) setContentView(binding.root) + + setupWindowInsets() + val masqueradingUserId: Long = intent.getLongExtra(Const.QR_CODE_MASQUERADE_ID, 0L) if (masqueradingUserId != 0L) { MasqueradeHelper.startMasquerading(masqueradingUserId, ApiPrefs.domain, NavigationActivity::class.java) @@ -439,6 +456,164 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } } + private fun setupWindowInsets() = with(binding) { + ViewCompat.setOnApplyWindowInsetsListener(fullscreen) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + // Apply both navigation bar and display cutout insets + // This ensures content is not hidden behind the navigation bar OR the hole punch camera + val leftPadding = maxOf(navigationBars.left, displayCutout.left) + val rightPadding = maxOf(navigationBars.right, displayCutout.right) + + view.setPadding( + leftPadding, + 0, + rightPadding, + 0 + ) + + // Consume horizontal insets so child ComposeViews don't apply them again + // Also consume bottom insets when offline indicator is visible + val masquerading = ApiPrefs.isMasquerading + val consumeBottom = offlineIndicator.root.visibility == View.VISIBLE + WindowInsetsCompat.Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + Insets.of( + 0, // Consume left + if (masquerading) 0 else navigationBars.top, + 0, // Consume right + if (consumeBottom) 0 else navigationBars.bottom + ) + ) + .setInsets( + WindowInsetsCompat.Type.displayCutout(), + Insets.of( + 0, // Consume left + if (masquerading) 0 else displayCutout.top, + 0, // Consume right + displayCutout.bottom + ) + ) + .build() + } + + ViewCompat.setOnApplyWindowInsetsListener(bottomBarContainer) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + // In landscape, use both padding for content and margins to move the bar away from edges + val leftInset = maxOf(navigationBars.left, displayCutout.left) + val rightInset = maxOf(navigationBars.right, displayCutout.right) + + view.setPadding( + leftInset, + view.paddingTop, + rightInset, + view.paddingBottom + ) + + view.updateLayoutParams { + this.leftMargin = leftInset + this.rightMargin = rightInset + this.bottomMargin = navigationBars.bottom + } + } else { + // In portrait, only apply display cutout and bottom navigation bar + view.setPadding( + displayCutout.left, + view.paddingTop, + displayCutout.right, + view.paddingBottom + ) + + view.updateLayoutParams { + this.leftMargin = 0 + this.rightMargin = 0 + this.bottomMargin = navigationBars.bottom + } + } + + // Update offline indicator margin based on bottom bar visibility + // When bottom bar is visible, no margin needed (bottom bar handles insets) + // When bottom bar is not visible, apply margin to clear Android nav buttons + val layoutParams = offlineIndicator.root.layoutParams as? ViewGroup.MarginLayoutParams + layoutParams?.bottomMargin = if (bottomBarContainer.isVisible) 0 else navigationBars.bottom + offlineIndicator.root.layoutParams = layoutParams + + insets + } + + ViewCompat.setOnApplyWindowInsetsListener(bottomBarContainer) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + bottomBarContainer.updateLayoutParams { + height = 56.toPx + navigationBars.bottom + } + insets + } + + ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBarDivider) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + // In landscape, apply horizontal margins to match bottom bar + val leftInset = maxOf(navigationBars.left, displayCutout.left) + val rightInset = maxOf(navigationBars.right, displayCutout.right) + + view.updateLayoutParams { + this.leftMargin = leftInset + this.rightMargin = rightInset + } + } else { + // In portrait, apply display cutout margins to match bottom bar + view.updateLayoutParams { + this.leftMargin = displayCutout.left + this.rightMargin = displayCutout.right + } + } + + insets + } + + ViewCompat.setOnApplyWindowInsetsListener(navigationDrawerBinding.navigationDrawer) { view, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.setPadding( + insets.left, + insets.top, + 0, + insets.bottom + ) + windowInsets + } + + ViewCompat.requestApplyInsets(bottomBarContainer) + } + + private fun updateStatusBarAppearanceForDrawer() { + // Check if drawer is open and update status bar appearance accordingly (handles config changes) + // Post to ensure drawer state is checked after current layout pass + binding.drawerLayout.post { + if (binding.drawerLayout.isDrawerOpen(GravityCompat.START) && !ColorKeeper.darkTheme) { + window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = true + } + } else if (!ColorKeeper.darkTheme) { + window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = false + } + } + } + } + private fun logOfflineEvents(isOnline: Boolean) { lifecycleScope.launch { if (isOnline) { @@ -480,8 +655,9 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. currentFragment?.let { val visible = isBottomNavFragment(it) || supportFragmentManager.backStackEntryCount <= 1 - binding.bottomBar.setVisible(visible) + binding.bottomBarContainer.setVisible(visible) binding.bottomBarDivider.setVisible(visible) + ViewCompat.requestApplyInsets(binding.bottomBarContainer) } } @@ -609,6 +785,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. mDrawerToggle?.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig) applyThemeForAllFragments() + updateStatusBarAppearanceForDrawer() } private fun applyThemeForAllFragments() { @@ -684,9 +861,9 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } } - // Hide these settings when dashboard redesign is enabled (they're now in customize dashboard) - val isDashboardRedesignEnabled = RemoteConfigUtils.getBoolean(RemoteConfigParam.DASHBOARD_REDESIGN) - if (isDashboardRedesignEnabled) { + // Hide these settings when the new dashboard is active (they're now in customize dashboard) + val isNewDashboardActive = navigationBehavior.homeFragmentClass == NewDashboardFragment::class.java + if (isNewDashboardActive) { navigationDrawerBinding.navigationDrawerSettingsSpacer.setGone() navigationDrawerBinding.navigationMenuItemsDivider.setGone() navigationDrawerBinding.optionsMenuTitle.setGone() @@ -732,6 +909,13 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. super.onDrawerOpened(drawerView) invalidateOptionsMenu() setCloseDrawerVisibility() + // Set status bar icons to dark only in light mode (for visibility on white drawer background) + if (!ColorKeeper.darkTheme) { + window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = true + } + } } override fun onDrawerClosed(drawerView: View) { @@ -739,6 +923,13 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. invalidateOptionsMenu() // Make the scrollview that is inside the drawer scroll to the top navigationDrawerBinding.navigationDrawer.scrollTo(0, 0) + // Restore status bar icons to light only in light mode (for dark toolbar) + if (!ColorKeeper.darkTheme) { + window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = false + } + } } } @@ -802,6 +993,8 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. findItem(R.id.bottomNavigationNotifications).isEnabled = !isOffline findItem(R.id.bottomNavigationInbox).isEnabled = !isOffline } + + ViewCompat.requestApplyInsets(binding.fullscreen) } override fun onStartMasquerading(domain: String, userId: Long) { @@ -1322,7 +1515,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } override fun updateToDoCount(toDoCount: Int) { - updateBottomBarBadge(R.id.bottomNavigationToDo, toDoCount, R.plurals.a11y_todoBadgeCount) + updateBottomBarBadge(R.id.bottomNavigationToDo, toDoCount, R.plurals.a11y_todoBadgeCountNew) } override fun onToDoCountChanged(count: Int) { diff --git a/apps/student/src/main/java/com/instructure/student/activity/NothingToSeeHereFragment.kt b/apps/student/src/main/java/com/instructure/student/activity/NothingToSeeHereFragment.kt index 2e52258429..df12d6725d 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NothingToSeeHereFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NothingToSeeHereFragment.kt @@ -24,6 +24,7 @@ import com.instructure.pandautils.analytics.SCREEN_VIEW_NOTHING_TO_SEE_HERE import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.setupAsBackButton import com.instructure.student.R import com.instructure.student.databinding.FragmentNothingToSeeHereBinding @@ -46,6 +47,7 @@ class NothingToSeeHereFragment : ParentFragment() { override fun applyTheme() { binding.toolbar.setupAsBackButton(this) + binding.toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarLight(requireActivity(), binding.toolbar) } diff --git a/apps/student/src/main/java/com/instructure/student/activity/PandaAvatarActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/PandaAvatarActivity.kt index 4acf0ee989..dae2ede4d6 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/PandaAvatarActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/PandaAvatarActivity.kt @@ -20,7 +20,12 @@ package com.instructure.student.activity import android.annotation.TargetApi import android.app.Activity import android.content.Intent -import android.graphics.* +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode import android.graphics.drawable.BitmapDrawable import android.os.Build import android.os.Bundle @@ -28,12 +33,19 @@ import android.os.Environment import android.view.Menu import android.view.MenuItem import android.view.View -import android.view.animation.* +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.view.animation.AnticipateInterpolator +import android.view.animation.OvershootInterpolator +import android.view.animation.TranslateAnimation import android.widget.ImageView import android.widget.LinearLayout import android.widget.RelativeLayout +import android.widget.ScrollView import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.instructure.canvasapi2.utils.APIHelper import com.instructure.canvasapi2.utils.Analytics import com.instructure.canvasapi2.utils.AnalyticsEventConstants @@ -41,14 +53,28 @@ import com.instructure.canvasapi2.utils.PrefManager import com.instructure.pandautils.analytics.SCREEN_VIEW_PANDA_AVATAR import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.ColorKeeper +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.DP +import com.instructure.pandautils.utils.EdgeToEdgeHelper +import com.instructure.pandautils.utils.PermissionUtils +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomAndRightSystemBarPadding +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.onClick +import com.instructure.pandautils.utils.requestAccessibilityFocus +import com.instructure.pandautils.utils.setGone +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.setupAsBackButton +import com.instructure.pandautils.utils.toast import com.instructure.student.R import com.instructure.student.databinding.PandaImageBinding import com.instructure.student.util.PandaDrawables import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.util.* +import java.util.Date private object PandaAvatarPrefs : PrefManager(Const.NAME) @@ -61,10 +87,12 @@ class PandaAvatarActivity : ParentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + EdgeToEdgeHelper.enableEdgeToEdge(this) setContentView(binding.root) setSupportActionBar(binding.toolbar) setupViews() setupListeners() + applyWindowInsets() Analytics.logEvent(AnalyticsEventConstants.PANDA_AVATAR_EDITOR_OPENED) } @@ -135,6 +163,7 @@ class PandaAvatarActivity : ParentActivity() { binding.toolbar.setupAsBackButton { finish() } ViewStyler.themeToolbarColored(this, binding.toolbar, ThemePrefs.primaryColor, ThemePrefs.primaryTextColor) binding.toolbar.elevation = this.DP(2f) + binding.toolbar.applyTopSystemBarInsets() // Make the head and body all black binding.changeHead.background = ColorKeeper.getColoredDrawable(this@PandaAvatarActivity, R.drawable.pandify_head_02, Color.BLACK) binding.changeBody.background = ColorKeeper.getColoredDrawable(this@PandaAvatarActivity, R.drawable.pandify_body_11, Color.BLACK) @@ -343,4 +372,40 @@ class PandaAvatarActivity : ParentActivity() { } } + private fun applyWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftPadding = maxOf(navigationBars.left, displayCutout.left) + val rightPadding = maxOf(navigationBars.right, displayCutout.right) + + view.setPadding( + leftPadding, + 0, + rightPadding, + 0 + ) + + // Apply bottom margin to editOptions + val editOptionsParams = binding.editOptions.layoutParams as? RelativeLayout.LayoutParams + editOptionsParams?.bottomMargin = systemBars.bottom + binding.editOptions.layoutParams = editOptionsParams + + // Apply bottom margin to partsOptions + val partsOptionsParams = binding.partsOptions.layoutParams as? RelativeLayout.LayoutParams + partsOptionsParams?.bottomMargin = systemBars.bottom + binding.partsOptions.layoutParams = partsOptionsParams + + insets + } + + // Apply bottom and right padding to ScrollView for landscape mode navigation bar + (binding.pandaImages.parent as? ScrollView)?.let { scrollView -> + scrollView.applyBottomAndRightSystemBarPadding() + scrollView.clipToPadding = false + } + } + } diff --git a/apps/student/src/main/java/com/instructure/student/activity/VideoViewActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/VideoViewActivity.kt index c0e8976bdd..3d42c6b056 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/VideoViewActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/VideoViewActivity.kt @@ -51,6 +51,7 @@ import com.instructure.pandautils.base.BaseCanvasActivity import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applySystemBarInsets import com.instructure.pandautils.utils.setGone import com.instructure.student.R import com.instructure.student.databinding.ActivityVideoViewBinding @@ -71,6 +72,7 @@ class VideoViewActivity : BaseCanvasActivity() { super.onCreate(savedInstanceState) setContentView(binding.root) binding.playerView.requestFocus() + binding.playerView.applySystemBarInsets(bottom = true) mediaDataSourceFactory = buildDataSourceFactory(true) mainHandler = Handler() val videoTrackSelectionFactory: ExoTrackSelection.Factory = AdaptiveTrackSelection.Factory() diff --git a/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt b/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt index 72b57b1a64..77b398e065 100644 --- a/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/coursebrowser/CourseBrowserFragment.kt @@ -27,6 +27,9 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.AppBarLayout @@ -34,6 +37,7 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Group import com.instructure.canvasapi2.models.Tab +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.RemoteConfigParam import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.canvasapi2.utils.isValid @@ -59,6 +63,8 @@ import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.a11yManager +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.isSwitchAccessEnabled import com.instructure.pandautils.utils.makeBundle @@ -216,6 +222,40 @@ class CourseBrowserFragment : BaseCanvasFragment(), FragmentInteractions, noOverlayToolbar.title = canvasContext.name (canvasContext as? Course)?.term?.name?.let { noOverlayToolbar.subtitle = it } noOverlayToolbar.setBackgroundColor(canvasContext.color) + appBarLayout.setBackgroundColor(canvasContext.color) + + // Apply top padding to noOverlayToolbar (skip when masquerading - MasqueradeUI handles it) + if (!ApiPrefs.isMasquerading) { + noOverlayToolbar.applyTopSystemBarInsets() + } + + // Handle insets for color overlay mode + ViewCompat.setOnApplyWindowInsetsListener(appBarLayout) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + + // Skip top insets when masquerading - MasqueradeUI handles it + val topInset = if (ApiPrefs.isMasquerading) 0 else systemBars.top + + if (overlayToolbar.isVisible) { + // Color overlay enabled: apply padding to push content below status bar + view.setPadding(view.paddingLeft, topInset, view.paddingRight, view.paddingBottom) + } else { + view.setPadding(view.paddingLeft, 0, view.paddingRight, view.paddingBottom) + } + + // Reset any margins + val layoutParams = view.layoutParams as? ViewGroup.MarginLayoutParams + layoutParams?.topMargin = 0 + view.layoutParams = layoutParams + + insets + } + + ViewCompat.requestApplyInsets(appBarLayout) + + // Add a status bar background view when color overlay is enabled + setupStatusBarBackground() + updateToolbarVisibility() // Hide image placeholder if color overlay is disabled and there is no valid image @@ -233,6 +273,8 @@ class CourseBrowserFragment : BaseCanvasFragment(), FragmentInteractions, swipeRefreshLayout.setOnRefreshListener { loadTabs(true) } + swipeRefreshLayout.applyBottomSystemBarInsets() + loadTabs() } @@ -273,6 +315,44 @@ class CourseBrowserFragment : BaseCanvasFragment(), FragmentInteractions, courseHeader.setVisible(useOverlay) } + private var statusBarBackgroundView: View? = null + + private fun setupStatusBarBackground() { + if (hideColorOverlay) { + // Remove status bar background if it exists + statusBarBackgroundView?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + statusBarBackgroundView = null + return + } + + // Find the fullscreen container in the activity + val fullscreenContainer = activity?.findViewById(R.id.fullscreen) ?: return + + // Create or update the status bar background view + if (statusBarBackgroundView == null) { + statusBarBackgroundView = View(requireContext()).apply { + id = View.generateViewId() + } + fullscreenContainer.addView(statusBarBackgroundView, 0) + } + + statusBarBackgroundView?.apply { + setBackgroundColor(canvasContext.color) + ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val params = view.layoutParams + ?: ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, systemBars.top) + params.width = ViewGroup.LayoutParams.MATCH_PARENT + params.height = systemBars.top + view.layoutParams = params + insets + } + ViewCompat.requestApplyInsets(this) + } + } + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) // Set course image again after orientation change to ensure correct scale/crop @@ -288,6 +368,11 @@ class CourseBrowserFragment : BaseCanvasFragment(), FragmentInteractions, override fun onDestroyView() { super.onDestroyView() apiCalls?.cancel() + // Clean up the status bar background view + statusBarBackgroundView?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + statusBarBackgroundView = null } //endregion diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt index 1885bf560e..7c8ab93853 100644 --- a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -141,6 +142,7 @@ fun DashboardScreenContent( Scaffold( modifier = Modifier.background(colorResource(R.color.backgroundLight)), + contentWindowInsets = WindowInsets(0), topBar = { CanvasThemedAppBar( title = stringResource(id = R.string.dashboard), diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardViewModel.kt index 40d834e96c..bf40200637 100644 --- a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardViewModel.kt +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardViewModel.kt @@ -84,7 +84,7 @@ class DashboardViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(loading = true, error = null) } try { - launch { ensureDefaultWidgetsUseCase(Unit) } + ensureDefaultWidgetsUseCase(Unit) combine( observeWidgetMetadataUseCase(Unit), networkStateProvider.isOnlineLiveData.asFlow() diff --git a/apps/student/src/main/java/com/instructure/student/features/discussion/details/DiscussionDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/discussion/details/DiscussionDetailsFragment.kt index 5c01d98535..9c7248bc14 100644 --- a/apps/student/src/main/java/com/instructure/student/features/discussion/details/DiscussionDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/discussion/details/DiscussionDetailsFragment.kt @@ -80,6 +80,8 @@ import com.instructure.pandautils.utils.ProfileUtils import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.Utils import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.getModuleItemId import com.instructure.pandautils.utils.isAccessibilityEnabled import com.instructure.pandautils.utils.isGroup @@ -178,6 +180,7 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { // Send out bus events to trigger a refresh for discussion list DiscussionUpdatedEvent(discussionTopicHeader, javaClass.simpleName).post() } + binding.discussionsScrollView.applyBottomSystemBarInsets() networkStateProvider.isOnlineLiveData.observe(viewLifecycleOwner) { isOnline -> if (isOnline) { @@ -243,6 +246,7 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { } */ ViewStyler.themeToolbarColored(requireActivity(), toolbar, canvasContext) + toolbar.applyTopSystemBarInsets() } } diff --git a/apps/student/src/main/java/com/instructure/student/features/discussion/list/DiscussionListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/discussion/list/DiscussionListFragment.kt index 09be1b28f9..df45a79059 100644 --- a/apps/student/src/main/java/com/instructure/student/features/discussion/list/DiscussionListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/discussion/list/DiscussionListFragment.kt @@ -52,6 +52,9 @@ import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.addSearch +import com.instructure.pandautils.utils.applyBottomAndRightSystemBarMargin +import com.instructure.pandautils.utils.applyImeAndSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.closeSearch import com.instructure.pandautils.utils.collectOneOffEvents import com.instructure.pandautils.utils.isTablet @@ -183,6 +186,7 @@ open class DiscussionListFragment : ParentFragment(), Bookmarkable { }) createNewDiscussion.apply { + applyBottomAndRightSystemBarMargin() setGone() backgroundTintList = ViewStyler.makeColorStateListForButton() setImageDrawable(ColorUtils.colorIt(ThemePrefs.buttonTextColor, drawable)) @@ -246,6 +250,9 @@ open class DiscussionListFragment : ParentFragment(), Bookmarkable { override fun applyTheme() { with(binding) { setupToolbarMenu(discussionListToolbar) + discussionListToolbar.applyTopSystemBarInsets() + swipeRefreshLayout.applyImeAndSystemBarInsets() + discussionRecyclerView.clipToPadding = false discussionListToolbar.title = title() discussionListToolbar.setupAsBackButton(this@DiscussionListFragment) val searchHint = diff --git a/apps/student/src/main/java/com/instructure/student/features/elementary/course/ElementaryCourseFragment.kt b/apps/student/src/main/java/com/instructure/student/features/elementary/course/ElementaryCourseFragment.kt index a31806d943..49fac73c3a 100644 --- a/apps/student/src/main/java/com/instructure/student/features/elementary/course/ElementaryCourseFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/elementary/course/ElementaryCourseFragment.kt @@ -21,7 +21,6 @@ import android.os.Handler import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import com.instructure.pandautils.base.BaseCanvasFragment import androidx.fragment.app.viewModels import androidx.lifecycle.Observer import com.google.android.material.tabs.TabLayout @@ -32,7 +31,17 @@ import com.instructure.canvasapi2.utils.pageview.PageViewUrlParam import com.instructure.interactions.router.Route import com.instructure.pandautils.analytics.SCREEN_VIEW_ELEMENTARY_COURSE import com.instructure.pandautils.analytics.ScreenView -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.base.BaseCanvasFragment +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.StringArg +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.argsWithContext +import com.instructure.pandautils.utils.isCourse +import com.instructure.pandautils.utils.isTablet +import com.instructure.pandautils.utils.setupAsBackButton import com.instructure.student.databinding.FragmentElementaryCourseBinding import com.instructure.student.features.coursebrowser.CourseBrowserFragment import com.instructure.student.features.grades.GradesFragment @@ -89,6 +98,7 @@ class ElementaryCourseFragment : BaseCanvasFragment() { data?.let { courseTabPager.offscreenPageLimit = it.tabs.size courseTabPager.adapter = ElementaryCoursePagerAdapter(it.tabs) + courseTabPager.applyBottomSystemBarInsets() val selectedTab = it.tabs.find { it.tabId == tabId } val selectedTabPosition = it.tabs.indexOf(selectedTab) @@ -112,6 +122,7 @@ class ElementaryCourseFragment : BaseCanvasFragment() { private fun applyTheme() = with(binding) { toolbar.title = canvasContext.name toolbar.setupAsBackButton(this@ElementaryCourseFragment) + toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), toolbar, canvasContext) } diff --git a/apps/student/src/main/java/com/instructure/student/features/files/details/FileDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/files/details/FileDetailsFragment.kt index 0483d15691..05fdcc4d5f 100644 --- a/apps/student/src/main/java/com/instructure/student/features/files/details/FileDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/files/details/FileDetailsFragment.kt @@ -50,6 +50,8 @@ import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.PermissionUtils import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.getModuleItemId import com.instructure.pandautils.utils.makeBundle import com.instructure.pandautils.utils.setGone @@ -117,6 +119,8 @@ class FileDetailsFragment : ParentFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.downloadButton.setVisible(repository.isOnline()) + binding.toolbar.applyTopSystemBarInsets() + binding.buttonContainer.applyBottomSystemBarInsets() } override fun onActivityCreated(savedInstanceState: Bundle?) { diff --git a/apps/student/src/main/java/com/instructure/student/features/files/list/FileListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/files/list/FileListFragment.kt index 89657b7fb6..e5b72c3777 100644 --- a/apps/student/src/main/java/com/instructure/student/features/files/list/FileListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/files/list/FileListFragment.kt @@ -71,6 +71,9 @@ import com.instructure.pandautils.utils.PermissionUtils import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyBottomSystemBarMargin +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.isCourse import com.instructure.pandautils.utils.isCourseOrGroup import com.instructure.pandautils.utils.isGroup @@ -191,6 +194,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent binding.toolbar.title = title() binding.toolbar.subtitle = canvasContext.name binding.addFab.setInvisible() + binding.addFab.applyBottomSystemBarMargin() binding.toolbar.setMenu(R.menu.menu_file_list) {} if (canvasContext.type == CanvasContext.Type.USER) applyTheme() @@ -311,6 +315,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent toolbar ) toolbar.setupAsBackButton(this@FileListFragment) + toolbar.applyTopSystemBarInsets() ViewStyler.themeFAB(addFab) ViewStyler.themeFAB(addFileFab) ViewStyler.themeFAB(addFolderFab) @@ -373,6 +378,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent } configureRecyclerView(requireView(), requireContext(), recyclerAdapter!!, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView) + swipeRefreshLayout.applyBottomSystemBarInsets() setupToolbarMenu(toolbar) diff --git a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchFragment.kt b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchFragment.kt index 03fe04ceca..95b6758d2f 100644 --- a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchFragment.kt @@ -21,6 +21,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.graphics.ColorUtils +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.recyclerview.widget.LinearLayoutManager import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.FileFolder @@ -35,6 +38,7 @@ import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.isUser import com.instructure.pandautils.utils.makeBundle @@ -88,6 +92,21 @@ class FileSearchFragment : ParentFragment(), FileSearchView { adapter = searchAdapter } setupViews() + setupWindowInsets() + } + + private fun setupWindowInsets() = with(binding) { + searchHeader.applyTopSystemBarInsets() + ViewCompat.setOnApplyWindowInsetsListener(fileSearchRecyclerView) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + view.updatePadding(bottom = maxOf(systemBars.bottom, ime.bottom)) + insets + } + fileSearchRecyclerView.clipToPadding = false + if (fileSearchRecyclerView.isAttachedToWindow) { + ViewCompat.requestApplyInsets(fileSearchRecyclerView) + } } override fun onRefreshStarted() { diff --git a/apps/student/src/main/java/com/instructure/student/features/inbox/list/StudentInboxRouter.kt b/apps/student/src/main/java/com/instructure/student/features/inbox/list/StudentInboxRouter.kt index 80237db782..7f3aba89c4 100644 --- a/apps/student/src/main/java/com/instructure/student/features/inbox/list/StudentInboxRouter.kt +++ b/apps/student/src/main/java/com/instructure/student/features/inbox/list/StudentInboxRouter.kt @@ -34,7 +34,7 @@ import com.instructure.student.router.RouteMatcher.openMedia class StudentInboxRouter(private val activity: FragmentActivity, private val fragment: Fragment) : InboxRouter { override fun openConversation(conversation: Conversation, scope: InboxApi.Scope) { - val route = InboxDetailsFragment.makeRoute(conversation.id, conversation.workflowState == Conversation.WorkflowState.UNREAD) + val route = InboxDetailsFragment.makeRoute(conversation.id, conversation.workflowState == Conversation.WorkflowState.UNREAD, scope) RouteMatcher.route(activity, route) } diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/list/ModuleListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/modules/list/ModuleListFragment.kt index b25e55b5cf..1352b67a2f 100644 --- a/apps/student/src/main/java/com/instructure/student/features/modules/list/ModuleListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/modules/list/ModuleListFragment.kt @@ -39,6 +39,8 @@ import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.isTablet import com.instructure.pandautils.utils.makeBundle import com.instructure.pandautils.utils.setVisible @@ -101,6 +103,7 @@ class ModuleListFragment : ParentFragment(), Bookmarkable { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) recyclerBinding = PandaRecyclerRefreshLayoutBinding.bind(binding.root) + binding.toolbar.applyTopSystemBarInsets() } override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -212,6 +215,7 @@ class ModuleListFragment : ParentFragment(), Bookmarkable { recyclerAdapter?.let { configureRecyclerView(requireView(), requireContext(), it, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView) } + recyclerBinding.swipeRefreshLayout.applyBottomSystemBarInsets() } fun notifyOfItemChanged(`object`: ModuleObject?, item: ModuleItem?) { diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/progression/CourseModuleProgressionFragment.kt b/apps/student/src/main/java/com/instructure/student/features/modules/progression/CourseModuleProgressionFragment.kt index 8ceddd27c8..25300735ed 100644 --- a/apps/student/src/main/java/com/instructure/student/features/modules/progression/CourseModuleProgressionFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/modules/progression/CourseModuleProgressionFragment.kt @@ -23,6 +23,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -52,6 +54,7 @@ import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.features.assignments.details.AssignmentDetailsFragment import com.instructure.pandautils.features.discussion.router.DiscussionRouteHelper import com.instructure.pandautils.features.discussion.router.DiscussionRouterFragment +import com.instructure.pandautils.features.offline.sync.StudioOfflineVideoHelper import com.instructure.pandautils.utils.BooleanArg import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.IntArg @@ -100,6 +103,9 @@ class CourseModuleProgressionFragment : ParentFragment(), Bookmarkable { @Inject lateinit var repository: ModuleProgressionRepository + @Inject + lateinit var studioOfflineVideoHelper: StudioOfflineVideoHelper + private var moduleItemsJob: Job? = null private var markAsReadJob: WeaveJob? = null @@ -149,6 +155,32 @@ class CourseModuleProgressionFragment : ParentFragment(), Bookmarkable { binding.prevItem.setImageDrawable(ColorKeeper.getColoredDrawable(requireActivity(), R.drawable.ic_chevron_left, canvasContext.color)) binding.nextItem.setImageDrawable(ColorKeeper.getColoredDrawable(requireActivity(), R.drawable.ic_chevron_right, canvasContext.color)) + + // Apply bottom margin to bottom bar for edge-to-edge support + ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBarModule) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val layoutParams = view.layoutParams as? ViewGroup.MarginLayoutParams + layoutParams?.bottomMargin = systemBars.bottom + view.layoutParams = layoutParams + insets + } + + // Consume bottom insets for fragment container to prevent child fragments from applying them again + ViewCompat.setOnApplyWindowInsetsListener(binding.fragmentContainer) { view, insets -> + WindowInsetsCompat.Builder(insets) + .setInsets( + WindowInsetsCompat.Type.systemBars(), + androidx.core.graphics.Insets.of( + insets.getInsets(WindowInsetsCompat.Type.systemBars()).left, + insets.getInsets(WindowInsetsCompat.Type.systemBars()).top, + insets.getInsets(WindowInsetsCompat.Type.systemBars()).right, + 0 // Set bottom to 0 to consume bottom insets + ) + ) + .build() + } + + ViewCompat.requestApplyInsets(binding.bottomBarModule) } override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -671,7 +703,8 @@ class CourseModuleProgressionFragment : ParentFragment(), Bookmarkable { repository.isOnline() || !isOfflineEnabled, // If the offline feature is disabled we always use the online behavior snycedTabs, syncedFileIds, - requireContext() + requireContext(), + studioOfflineVideoHelper = if (isOfflineEnabled) studioOfflineVideoHelper else null ) var args: Bundle? = fragment!!.arguments if (args == null) { diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/progression/LockedModuleItemFragment.kt b/apps/student/src/main/java/com/instructure/student/features/modules/progression/LockedModuleItemFragment.kt index 806f82655c..8a9de6c3d7 100644 --- a/apps/student/src/main/java/com/instructure/student/features/modules/progression/LockedModuleItemFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/modules/progression/LockedModuleItemFragment.kt @@ -33,6 +33,7 @@ import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.setupAsBackButton import com.instructure.pandautils.views.CanvasWebView import com.instructure.student.R @@ -62,6 +63,7 @@ class LockedModuleItemFragment : ParentFragment() { binding.explanationWebView.loadHtml(lockExplanation, "") binding.toolbar.setupAsBackButton(this) + binding.toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), binding.toolbar, course) } //endregion diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/progression/ModuleQuizDecider.kt b/apps/student/src/main/java/com/instructure/student/features/modules/progression/ModuleQuizDecider.kt index d87a077543..6cc031da52 100644 --- a/apps/student/src/main/java/com/instructure/student/features/modules/progression/ModuleQuizDecider.kt +++ b/apps/student/src/main/java/com/instructure/student/features/modules/progression/ModuleQuizDecider.kt @@ -41,6 +41,7 @@ import com.instructure.pandautils.utils.LongArg import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.argsWithContext import com.instructure.pandautils.utils.onClickWithRequireNetwork import com.instructure.pandautils.utils.setGone @@ -113,6 +114,7 @@ class ModuleQuizDecider : ParentFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.toolbar.applyTopSystemBarInsets() obtainQuiz() } diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/progression/NotAvailableOfflineFragment.kt b/apps/student/src/main/java/com/instructure/student/features/modules/progression/NotAvailableOfflineFragment.kt index 643c08acf7..87aa6e7f36 100644 --- a/apps/student/src/main/java/com/instructure/student/features/modules/progression/NotAvailableOfflineFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/modules/progression/NotAvailableOfflineFragment.kt @@ -57,6 +57,7 @@ class NotAvailableOfflineFragment : ParentFragment() { if (showToolbar) { binding.toolbar.title = moduleItemName binding.toolbar.setupAsBackButton(this) + binding.toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), binding.toolbar, course) } else { binding.toolbar.setGone() diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/progression/StudioVideoFragment.kt b/apps/student/src/main/java/com/instructure/student/features/modules/progression/StudioVideoFragment.kt new file mode 100644 index 0000000000..f5acdb1a3a --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/modules/progression/StudioVideoFragment.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.student.features.modules.progression + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.interactions.router.Route +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.utils.LongArg +import com.instructure.pandautils.utils.NullableStringArg +import com.instructure.pandautils.utils.StringArg +import com.instructure.pandautils.utils.color +import com.instructure.student.activity.VideoViewActivity +import com.instructure.student.fragment.ParentFragment +import dagger.hilt.android.AndroidEntryPoint + +private const val COURSE_ID = "course_id" +private const val VIDEO_URI = "video_uri" +private const val VIDEO_TITLE = "video_title" +private const val POSTER_URI = "poster_uri" + +@AndroidEntryPoint +class StudioVideoFragment : ParentFragment() { + + private var courseId: Long by LongArg(key = COURSE_ID) + private var videoUri: String by StringArg(key = VIDEO_URI) + private var videoTitle: String by StringArg(key = VIDEO_TITLE) + private var posterUri: String? by NullableStringArg(key = POSTER_URI) + + override fun title(): String = videoTitle + + override fun applyTheme() {} + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return ComposeView(requireContext()).apply { + setContent { + CanvasTheme { + val courseColor = Color(CanvasContext.emptyCourseContext(courseId).color) + StudioVideoScreen( + title = videoTitle, + posterUri = posterUri, + courseColor = courseColor, + onOpenClick = { + startActivity(VideoViewActivity.createIntent(requireContext(), videoUri)) + }, + onBackClick = { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + ) + } + } + } + } + + companion object { + fun makeRoute(courseId: Long, videoUri: String, title: String, posterUri: String?): Route { + val bundle = Bundle().apply { + putLong(COURSE_ID, courseId) + putString(VIDEO_URI, videoUri) + putString(VIDEO_TITLE, title) + putString(POSTER_URI, posterUri) + } + return Route(StudioVideoFragment::class.java, null, bundle) + } + + fun newInstance(route: Route) = StudioVideoFragment().apply { + arguments = route.arguments + } + } +} diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/progression/StudioVideoScreen.kt b/apps/student/src/main/java/com/instructure/student/features/modules/progression/StudioVideoScreen.kt new file mode 100644 index 0000000000..185070eb7a --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/modules/progression/StudioVideoScreen.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.student.features.modules.progression + +import android.content.res.Configuration +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.compose.composables.CanvasThemedAppBar +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.student.R + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun StudioVideoScreen( + title: String, + posterUri: String?, + courseColor: Color = Color(ThemePrefs.primaryColor), + onOpenClick: () -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + CanvasThemedAppBar( + title = title, + navigationActionClick = onBackClick, + backgroundColor = courseColor, + contentColor = colorResource(R.color.textLightest) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(colorResource(R.color.backgroundLightest)) + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (posterUri != null) { + GlideImage( + model = posterUri, + contentDescription = title, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentScale = ContentScale.Fit + ) + } else { + Box( + modifier = Modifier + .size(120.dp) + .background( + color = colorResource(id = R.color.backgroundMedium), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_media), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = colorResource(id = R.color.textDarkest) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = title, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = colorResource(id = R.color.textDarkest) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onOpenClick, + colors = ButtonDefaults.buttonColors( + containerColor = Color(ThemePrefs.brandColor) + ) + ) { + Text( + text = stringResource(id = R.string.open), + color = colorResource(R.color.textLightest) + ) + } + } + } +} + +@Preview +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun StudioVideoScreenPreview() { + ContextKeeper.appContext = LocalContext.current + StudioVideoScreen( + title = "Module Video", + posterUri = null, + onOpenClick = {}, + onBackClick = {} + ) +} diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/util/ModuleUtility.kt b/apps/student/src/main/java/com/instructure/student/features/modules/util/ModuleUtility.kt index e64c3035dc..5b07957ba8 100644 --- a/apps/student/src/main/java/com/instructure/student/features/modules/util/ModuleUtility.kt +++ b/apps/student/src/main/java/com/instructure/student/features/modules/util/ModuleUtility.kt @@ -31,12 +31,14 @@ import com.instructure.pandautils.features.assignments.details.AssignmentDetails import com.instructure.pandautils.features.assignments.details.AssignmentDetailsFragment.Companion.makeRoute import com.instructure.pandautils.features.discussion.details.DiscussionDetailsWebViewFragment import com.instructure.student.R +import com.instructure.pandautils.features.offline.sync.StudioOfflineVideoHelper import com.instructure.student.features.discussion.details.DiscussionDetailsFragment import com.instructure.student.features.discussion.details.DiscussionDetailsFragment.Companion.makeRoute import com.instructure.student.features.files.details.FileDetailsFragment import com.instructure.student.features.modules.progression.LockedModuleItemFragment import com.instructure.student.features.modules.progression.ModuleQuizDecider import com.instructure.student.features.modules.progression.NotAvailableOfflineFragment +import com.instructure.student.features.modules.progression.StudioVideoFragment import com.instructure.student.features.pages.details.PageDetailsFragment import com.instructure.student.fragment.InternalWebviewFragment import com.instructure.student.fragment.InternalWebviewFragment.Companion.makeRoute @@ -53,7 +55,8 @@ object ModuleUtility { isOnline: Boolean, syncedTabs: Set, syncedFileIds: List, - context: Context + context: Context, + studioOfflineVideoHelper: StudioOfflineVideoHelper? = null ): Fragment? = when (item.type) { "Page" -> PageDetailsFragment.newInstance(PageDetailsFragment.makeRoute(course, item.title, item.pageUrl, navigatedFromModules)) "Assignment" -> { @@ -85,6 +88,22 @@ object ModuleUtility { "ExternalUrl", "ExternalTool" -> { if (item.isLocked()) { LockedModuleItemFragment.newInstance(LockedModuleItemFragment.makeRoute(course, item.title!!, item.moduleDetails?.lockExplanation ?: "")) + } else if (!isOnline && studioOfflineVideoHelper != null) { + val mediaId = studioOfflineVideoHelper.getStudioMediaId(item.externalUrl) + if (mediaId != null && studioOfflineVideoHelper.isStudioVideoAvailableOffline(mediaId)) { + StudioVideoFragment.newInstance( + StudioVideoFragment.makeRoute( + course.id, + studioOfflineVideoHelper.getStudioVideoUri(mediaId), + item.title ?: "", + studioOfflineVideoHelper.getStudioPosterUri(mediaId) + ) + ) + } else { + NotAvailableOfflineFragment.newInstance( + NotAvailableOfflineFragment.makeRoute(course, item.title, context.getString(R.string.notAvailableOfflineDescription)) + ) + } } else { createFragmentWithOfflineCheck(isOnline, course, item, syncedTabs, context) { val uri = Uri.parse(item.htmlUrl).buildUpon().appendQueryParameter("display", "borderless").build() diff --git a/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt index edbbc5abd9..2a28293c36 100644 --- a/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/pages/details/PageDetailsFragment.kt @@ -50,6 +50,8 @@ import com.instructure.pandautils.utils.FileDownloader import com.instructure.pandautils.utils.NullableStringArg import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.getModuleItemId import com.instructure.pandautils.utils.loadHtmlWithIframes import com.instructure.pandautils.utils.makeBundle @@ -311,6 +313,8 @@ class PageDetailsFragment : InternalWebviewFragment(), Bookmarkable { } override fun applyTheme() { + binding.toolbar.applyTopSystemBarInsets() + binding.canvasWebViewWrapper.applyBottomSystemBarInsets() binding.toolbar.let { setupToolbarMenu(it, R.menu.menu_page_details) it.title = title() diff --git a/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt index 13938daf88..5bdbf27ac3 100644 --- a/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt @@ -34,7 +34,17 @@ import com.instructure.interactions.router.RouterParams import com.instructure.pandautils.analytics.SCREEN_VIEW_PAGE_LIST import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.BooleanArg +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.addSearch +import com.instructure.pandautils.utils.applyImeAndSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.closeSearch +import com.instructure.pandautils.utils.isTablet +import com.instructure.pandautils.utils.makeBundle +import com.instructure.pandautils.utils.setupAsBackButton import com.instructure.student.R import com.instructure.student.databinding.FragmentCoursePagesBinding import com.instructure.student.databinding.PandaRecyclerRefreshLayoutBinding @@ -125,6 +135,8 @@ class PageListFragment : ParentFragment(), Bookmarkable { recyclerAdapter?.let { configureRecyclerView(rootView!!, requireContext(), it, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView) } + recyclerBinding.swipeRefreshLayout.applyImeAndSystemBarInsets() + recyclerBinding.listView.clipToPadding = false } override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -171,6 +183,7 @@ class PageListFragment : ParentFragment(), Bookmarkable { setupToolbarMenu(toolbar) toolbar.title = title() toolbar.setupAsBackButton(this@PageListFragment) + toolbar.applyTopSystemBarInsets() toolbar.addSearch(getString(R.string.searchPagesHint)) { query -> if (query.isBlank()) { recyclerBinding.emptyView.emptyViewText(R.string.noItemsToDisplayShort) diff --git a/apps/student/src/main/java/com/instructure/student/features/people/details/PeopleDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/people/details/PeopleDetailsFragment.kt index 758280833e..63b8e67d1d 100644 --- a/apps/student/src/main/java/com/instructure/student/features/people/details/PeopleDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/people/details/PeopleDetailsFragment.kt @@ -58,6 +58,8 @@ import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.isCourse import com.instructure.pandautils.utils.makeBundle import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.applyBottomSystemBarMargin +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.toast import com.instructure.pandautils.utils.withArgs import com.instructure.student.R @@ -98,8 +100,11 @@ class PeopleDetailsFragment : ParentFragment(), Bookmarkable { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.userBackground.applyTopSystemBarInsets() + binding.avatar.applyTopSystemBarInsets() binding.compose.backgroundTintList = ColorStateList.valueOf(ThemePrefs.buttonColor) binding.compose.setImageDrawable(ColorKeeper.getColoredDrawable(requireContext(), R.drawable.ic_send, ThemePrefs.buttonTextColor)) + binding.compose.applyBottomSystemBarMargin() binding.compose.setOnClickListener { // Messaging other users is not available in Student view val route = if (ApiPrefs.isStudentView) NothingToSeeHereFragment.makeRoute() else { diff --git a/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListFragment.kt index fa683d01bf..7b7096821d 100644 --- a/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/people/list/PeopleListFragment.kt @@ -32,7 +32,15 @@ import com.instructure.interactions.router.Route import com.instructure.pandautils.analytics.SCREEN_VIEW_PEOPLE_LIST import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.isCourse +import com.instructure.pandautils.utils.makeBundle +import com.instructure.pandautils.utils.setupAsBackButton +import com.instructure.pandautils.utils.withArgs import com.instructure.student.R import com.instructure.student.databinding.FragmentPeopleListBinding import com.instructure.student.features.people.details.PeopleDetailsFragment @@ -88,12 +96,14 @@ class PeopleListFragment : ParentFragment(), Bookmarkable { R.id.emptyView, R.id.listView ) + view.findViewById(R.id.swipeRefreshLayout)?.applyBottomSystemBarInsets() } override fun applyTheme() { with(binding) { toolbar.title = title() toolbar.setupAsBackButton(this@PeopleListFragment) + toolbar.applyTopSystemBarInsets() setupToolbarMenu(toolbar) ViewStyler.themeToolbarColored(requireActivity(), toolbar, canvasContext) } diff --git a/apps/student/src/main/java/com/instructure/student/features/quiz/list/QuizListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/quiz/list/QuizListFragment.kt index fda4afb086..531d45f3e7 100644 --- a/apps/student/src/main/java/com/instructure/student/features/quiz/list/QuizListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/quiz/list/QuizListFragment.kt @@ -41,6 +41,8 @@ import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.addSearch +import com.instructure.pandautils.utils.applyImeAndSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.closeSearch import com.instructure.pandautils.utils.isTablet import com.instructure.pandautils.utils.makeBundle @@ -99,6 +101,8 @@ class QuizListFragment : ParentFragment(), Bookmarkable { R.id.emptyView, R.id.listView ) + recyclerBinding.swipeRefreshLayout.applyImeAndSystemBarInsets() + recyclerBinding.listView.clipToPadding = false } override fun applyTheme() { @@ -106,6 +110,7 @@ class QuizListFragment : ParentFragment(), Bookmarkable { setupToolbarMenu(toolbar) toolbar.title = title() toolbar.setupAsBackButton(this@QuizListFragment) + toolbar.applyTopSystemBarInsets() toolbar.addSearch(getString(R.string.searchQuizzesHint)) { query -> if (query.isBlank()) { recyclerBinding.emptyView.emptyViewText(R.string.noItemsToDisplayShort) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/AccountPreferencesFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/AccountPreferencesFragment.kt index 2973b7707a..7c4b222166 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/AccountPreferencesFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/AccountPreferencesFragment.kt @@ -34,6 +34,8 @@ import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.setVisible import com.instructure.pandautils.utils.setupAsBackButton import com.instructure.student.BuildConfig @@ -80,10 +82,12 @@ class AccountPreferencesFragment : ParentFragment() { override fun applyTheme() = with(binding) { toolbar.title = title() toolbar.setupAsBackButton(this@AccountPreferencesFragment) + toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), toolbar, ThemePrefs.primaryColor, ThemePrefs.primaryTextColor) } private fun setupViews() = with(binding) { + scrollView.applyBottomSystemBarInsets() if (BuildConfig.DEBUG) { // Only show language override picker in debug builds languageContainer.setVisible() diff --git a/apps/student/src/main/java/com/instructure/student/fragment/AssignmentBasicFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/AssignmentBasicFragment.kt index 3241f92107..f290ee843f 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/AssignmentBasicFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/AssignmentBasicFragment.kt @@ -36,6 +36,7 @@ import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.OnBackStackChangedEvent import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.argsWithContext import com.instructure.pandautils.utils.loadHtmlWithIframes import com.instructure.pandautils.utils.setGone @@ -186,6 +187,7 @@ class AssignmentBasicFragment : ParentFragment() { binding.toolbar.let { it.title = assignment.name ?: "" it.setupAsBackButton(this) + it.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), it, canvasContext) } } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/BookmarksFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/BookmarksFragment.kt index af2dbf27e3..901ab4f897 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/BookmarksFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/BookmarksFragment.kt @@ -41,6 +41,8 @@ import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.hideKeyboard import com.instructure.pandautils.utils.isTablet import com.instructure.pandautils.utils.setupAsBackButton @@ -105,6 +107,7 @@ class BookmarksFragment : ParentFragment() { } } + toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), toolbar, ThemePrefs.primaryColor, ThemePrefs.primaryTextColor) } @@ -126,6 +129,7 @@ class BookmarksFragment : ParentFragment() { configureRecyclerView(requireView(), requireContext(), recyclerAdapter!!, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView, R.string.no_bookmarks) pandaRecyclerBinding.listView.addItemDecoration(DividerDecoration(requireContext())) pandaRecyclerBinding.listView.isSelectionEnabled = false + pandaRecyclerBinding.swipeRefreshLayout.applyBottomSystemBarInsets() } private fun configureRecyclerAdapter() { diff --git a/apps/student/src/main/java/com/instructure/student/fragment/CourseSettingsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/CourseSettingsFragment.kt index 639c1089e4..88a95d8ca8 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/CourseSettingsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/CourseSettingsFragment.kt @@ -29,7 +29,15 @@ import com.instructure.interactions.router.Route import com.instructure.pandautils.analytics.SCREEN_VIEW_COURSE_SETTINGS import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.makeBundle +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.setupAsBackButton +import com.instructure.pandautils.utils.withArgs import com.instructure.student.R import com.instructure.student.databinding.FragmentCourseSettingsBinding @@ -50,6 +58,7 @@ class CourseSettingsFragment : ParentFragment() { override fun applyTheme() { binding.toolbar.title = title() binding.toolbar.setupAsBackButton(this) + binding.toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), binding.toolbar, course) } @@ -67,6 +76,7 @@ class CourseSettingsFragment : ParentFragment() { startDate.text = DateHelper.dateToDayMonthYearString(requireContext(), course.startDate) endLayout.setVisible(course.endDate != null) endDate.text = DateHelper.dateToDayMonthYearString(requireContext(), course.endDate) + scrollView.applyBottomSystemBarInsets() } companion object { diff --git a/apps/student/src/main/java/com/instructure/student/fragment/EditPageDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/EditPageDetailsFragment.kt index 024589e5da..9632031bf1 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/EditPageDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/EditPageDetailsFragment.kt @@ -19,6 +19,7 @@ package com.instructure.student.fragment import android.app.Activity import android.content.Intent +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem @@ -40,7 +41,20 @@ import com.instructure.interactions.router.Route import com.instructure.pandautils.analytics.SCREEN_VIEW_EDIT_PAGE_DETAILS import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.MediaUploadUtils +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.RequestCodes +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.makeBundle +import com.instructure.pandautils.utils.setGone +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.setupAsCloseButton +import com.instructure.pandautils.utils.toast +import com.instructure.pandautils.utils.withArgs import com.instructure.student.R import com.instructure.student.databinding.FragmentEditPageBinding import com.instructure.student.dialog.UnsavedChangesExitDialog @@ -74,6 +88,11 @@ class EditPageDetailsFragment : ParentFragment() { return url.toString() } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ViewStyler.setStatusBarLightDelayed(requireActivity()) + } + //region Fragment Lifecycle Overrides override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = inflater.inflate(R.layout.fragment_edit_page, container, false) @@ -82,6 +101,7 @@ class EditPageDetailsFragment : ParentFragment() { super.onViewCreated(view, savedInstanceState) setupToolbar() setupDescription() + binding.pageRCEView.applyBottomSystemBarInsets() } override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -121,7 +141,9 @@ class EditPageDetailsFragment : ParentFragment() { //endregion //region Fragment Interaction Overrides - override fun applyTheme() = Unit + override fun applyTheme() { + ViewStyler.themeToolbarLight(requireActivity(), binding.toolbar) + } override fun title(): String = getString(R.string.editPage) //endregion @@ -192,6 +214,7 @@ class EditPageDetailsFragment : ParentFragment() { } } toolbar.title = page.title + toolbar.applyTopSystemBarInsets() setupToolbarMenu(toolbar, R.menu.menu_edit_page) ViewStyler.themeToolbarLight(requireActivity(), toolbar) ViewStyler.setToolbarElevationSmall(requireContext(), toolbar) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/FeatureFlagsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/FeatureFlagsFragment.kt index 367b70e22a..2ec81b3b71 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/FeatureFlagsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/FeatureFlagsFragment.kt @@ -26,6 +26,8 @@ import com.instructure.canvasapi2.utils.FeatureFlagPref import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.setupAsBackButton import com.instructure.student.R import com.instructure.student.databinding.AdapterFeatureFlagBinding @@ -44,10 +46,12 @@ class FeatureFlagsFragment : BaseCanvasFragment() { super.onViewCreated(view, savedInstanceState) setupToolbar() binding.recyclerView.adapter = FeatureFlagAdapter() + binding.recyclerView.applyBottomSystemBarInsets() } private fun setupToolbar() { binding.toolbar.setupAsBackButton(this) + binding.toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), binding.toolbar, ThemePrefs.primaryColor, ThemePrefs.primaryTextColor) } } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt index 4820ea9d1a..b65c4761a4 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt @@ -55,6 +55,8 @@ import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.PermissionUtils import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.argsWithContext import com.instructure.pandautils.utils.enableAlgorithmicDarkening import com.instructure.pandautils.utils.makeBundle @@ -233,6 +235,8 @@ open class InternalWebviewFragment : ParentFragment() { }) + canvasWebViewWrapper.applyBottomSystemBarInsets() + if (savedInstanceState != null) { canvasWebViewWrapper?.webView?.restoreState(savedInstanceState) } @@ -302,6 +306,7 @@ open class InternalWebviewFragment : ParentFragment() { override fun applyTheme() = with(binding) { toolbar.title = title() toolbar.setupAsBackButton(this@InternalWebviewFragment) + toolbar.applyTopSystemBarInsets() if (canvasContext.type != CanvasContext.Type.COURSE && canvasContext.type != CanvasContext.Type.GROUP) { ViewStyler.themeToolbarColored(requireActivity(), toolbar, ThemePrefs.primaryColor, ThemePrefs.primaryTextColor) } else { diff --git a/apps/student/src/main/java/com/instructure/student/fragment/NotificationListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/NotificationListFragment.kt index a8d19ad08c..6de0f8d9d4 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/NotificationListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/NotificationListFragment.kt @@ -54,6 +54,8 @@ import com.instructure.pandautils.features.inbox.details.InboxDetailsFragment import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.isCourse import com.instructure.pandautils.utils.isCourseOrGroup import com.instructure.pandautils.utils.isGroup @@ -189,10 +191,12 @@ class NotificationListFragment : ParentFragment(), Bookmarkable, FragmentManager val canvasContext = canvasContext if (canvasContext is Course || canvasContext is Group) { binding.toolbar.setupAsBackButton(this) + binding.toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), binding.toolbar, canvasContext) } else { val navigation = navigation navigation?.attachNavigationDrawer(this, binding.toolbar) + binding.toolbar.applyTopSystemBarInsets() // Styling done in attachNavigationDrawer } } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt index 9a68423101..c0eed9e2dc 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt @@ -30,6 +30,7 @@ import android.view.MotionEvent.ACTION_CANCEL import android.view.View import android.view.ViewGroup import androidx.lifecycle.lifecycleScope +import com.instructure.pandautils.utils.applyTopSystemBarInsets import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager @@ -59,6 +60,8 @@ import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.dialogs.ColorPickerDialog import com.instructure.pandautils.dialogs.EditCourseNicknameDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.instructure.canvasapi2.utils.Analytics +import com.instructure.canvasapi2.utils.AnalyticsParamConstants import com.instructure.canvasapi2.utils.RemoteConfigParam import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.pandautils.features.dashboard.DashboardCourseItem @@ -170,6 +173,8 @@ class OldDashboardFragment : ParentFragment() { applyTheme() + binding.toolbar.applyTopSystemBarInsets() + networkStateProvider.isOnlineLiveData.observe(this) { online -> recyclerAdapter?.refresh() if (online) recyclerBinding.swipeRefreshLayout.isRefreshing = true @@ -314,6 +319,7 @@ class OldDashboardFragment : ParentFragment() { .setTitle(R.string.changing_dashboard_layout) .setMessage(R.string.changing_dashboard_layout_message) .setPositiveButton(R.string.restart_now) { _, _ -> + Analytics.logEvent(AnalyticsParamConstants.NEW_DASHBOARD_ENABLED) lifecycleScope.launch { updateNewDashboardPreferenceUseCase(UpdateNewDashboardPreferenceUseCase.Params(true)) dashboardRouter.restartApp() diff --git a/apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt index c87f39ba29..f2e56a527f 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt @@ -42,6 +42,7 @@ import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.accessibilityClassName +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.children import com.instructure.pandautils.utils.isTablet import com.instructure.pandautils.utils.makeBundle @@ -99,7 +100,7 @@ class OldToDoListFragment : ParentFragment() { override fun onShowErrorCrouton(message: Int) = Unit } - override fun title(): String = getString(R.string.Todo) + override fun title(): String = getString(R.string.TodoNew) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return layoutInflater.inflate(R.layout.fragment_list_todo, container, false) @@ -154,6 +155,7 @@ class OldToDoListFragment : ParentFragment() { override fun applyTheme() { setupToolbarMenu(binding.toolbar) + binding.toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), binding.toolbar, ThemePrefs.primaryColor, ThemePrefs.primaryTextColor) } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/ProfileSettingsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/ProfileSettingsFragment.kt index 1648b3278b..b1d2369175 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/ProfileSettingsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/ProfileSettingsFragment.kt @@ -83,10 +83,12 @@ class ProfileSettingsFragment : ParentFragment(), LoaderManager.LoaderCallbacks< applyTheme() setupViews() getUserPermissions() + binding.scrollView.applyBottomSystemBarInsets() } override fun applyTheme() { binding.toolbar.setupAsBackButton(this) + binding.toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarColored(requireActivity(), binding.toolbar, ThemePrefs.primaryColor, ThemePrefs.primaryTextColor) } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/ViewImageFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/ViewImageFragment.kt index 0f11b2b3db..a51f8d859d 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/ViewImageFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/ViewImageFragment.kt @@ -16,6 +16,7 @@ */ package com.instructure.student.fragment +import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable @@ -24,7 +25,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import com.instructure.pandautils.base.BaseCanvasFragment import androidx.palette.graphics.Palette import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource @@ -32,10 +32,25 @@ import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target import com.instructure.interactions.router.Route +import com.instructure.pandautils.base.BaseCanvasFragment import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.interfaces.ShareableFile import com.instructure.pandautils.models.EditableFile -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.BooleanArg +import com.instructure.pandautils.utils.FileFolderDeletedEvent +import com.instructure.pandautils.utils.FileFolderUpdatedEvent +import com.instructure.pandautils.utils.IntArg +import com.instructure.pandautils.utils.NullableParcelableArg +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.StringArg +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.isTablet +import com.instructure.pandautils.utils.onClick +import com.instructure.pandautils.utils.setGone +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.setupAsBackButton +import com.instructure.pandautils.utils.viewExternally import com.instructure.student.R import com.instructure.student.databinding.FragmentViewImageBinding import org.greenrobot.eventbus.EventBus @@ -65,6 +80,11 @@ class ViewImageFragment : BaseCanvasFragment(), ShareableFile { if (mShowToolbar) setupToolbar() else binding.toolbar.setGone() } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ViewStyler.setStatusBarLightDelayed(requireActivity()) + } + private fun setupToolbar() = with(binding) { mEditableFile?.let { @@ -87,6 +107,8 @@ class ViewImageFragment : BaseCanvasFragment(), ShareableFile { ViewStyler.themeToolbarLight(requireActivity(), toolbar) ViewStyler.setToolbarElevationSmall(requireContext(), toolbar) } + + toolbar.applyTopSystemBarInsets() } private val requestListener = object : RequestListener { diff --git a/apps/student/src/main/java/com/instructure/student/fragment/ViewUnsupportedFileFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/ViewUnsupportedFileFragment.kt index abb06c8b44..9fb72bef72 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/ViewUnsupportedFileFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/ViewUnsupportedFileFragment.kt @@ -16,6 +16,7 @@ package com.instructure.student.fragment * along with this program. If not, see . */ +import android.content.res.Configuration import android.net.Uri import android.os.Bundle import android.view.LayoutInflater @@ -59,6 +60,12 @@ class ViewUnsupportedFileFragment : BaseCanvasFragment() { mEditableFile?.let { setupToolbar() } ?: binding.toolbar.setGone() } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ViewStyler.setStatusBarLightDelayed(requireActivity()) + } + private fun setupToolbar() = with(binding) { mEditableFile?.let { @@ -74,6 +81,7 @@ class ViewUnsupportedFileFragment : BaseCanvasFragment() { fileNameView.text = it.file.displayName } + toolbar.applyTopSystemBarInsets() if(isTablet && mToolbarColor != 0) { ViewStyler.themeToolbarColored(requireActivity(), toolbar, mToolbarColor, requireContext().getColor(R.color.white)) } else { diff --git a/apps/student/src/main/java/com/instructure/student/holders/TodoViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/TodoViewHolder.kt index 25eb647a20..75597802d8 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/TodoViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/TodoViewHolder.kt @@ -60,9 +60,9 @@ class TodoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private fun getContextNameForPlannerItem(context: Context, plannerItem: PlannerItem): String { return if (plannerItem.plannableType == PlannableType.PLANNER_NOTE) { if (plannerItem.contextName.isNullOrEmpty()) { - context.getString(R.string.userCalendarToDo) + context.getString(R.string.userCalendarToDoNew) } else { - context.getString(R.string.courseToDo, plannerItem.contextName) + context.getString(R.string.courseToDoNew, plannerItem.contextName) } } else { plannerItem.contextName.orEmpty() diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/annnotation/AnnotationSubmissionUploadFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/annnotation/AnnotationSubmissionUploadFragment.kt index 8f674daed0..d8f8c4c383 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/annnotation/AnnotationSubmissionUploadFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/annnotation/AnnotationSubmissionUploadFragment.kt @@ -16,6 +16,7 @@ */ package com.instructure.student.mobius.assignmentDetails.submission.annnotation +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -30,6 +31,8 @@ import com.instructure.pandautils.utils.LongArg import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.setMenu import com.instructure.pandautils.utils.setupAsBackButton import com.instructure.pandautils.utils.withArgs @@ -80,6 +83,8 @@ class AnnotationSubmissionUploadFragment : BaseCanvasFragment() { } setUpToolbar(binding.annotationSubmissionToolbar) + binding.annotationSubmissionToolbar.applyTopSystemBarInsets() + binding.annotationSubmissionViewContainer.applyBottomSystemBarInsets() return binding.root } @@ -97,6 +102,11 @@ class AnnotationSubmissionUploadFragment : BaseCanvasFragment() { ViewStyler.themeToolbarLight(requireActivity(), toolbar) } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ViewStyler.setStatusBarLightDelayed(requireActivity()) + } + companion object { private const val ANNOTATABLE_ATTACHMENT_ID = "annotatable_attachment_id" diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/file/ui/UploadStatusSubmissionView.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/file/ui/UploadStatusSubmissionView.kt index c83cd5479c..6d57dcfbe4 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/file/ui/UploadStatusSubmissionView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/file/ui/UploadStatusSubmissionView.kt @@ -18,6 +18,7 @@ package com.instructure.student.mobius.assignmentDetails.submission.file.ui import android.app.Activity import android.content.res.ColorStateList +import android.content.res.Configuration import android.view.LayoutInflater import android.view.ViewGroup import android.widget.Toast @@ -28,6 +29,9 @@ import com.instructure.pandautils.adapters.BasicItemBinder import com.instructure.pandautils.adapters.BasicItemCallback import com.instructure.pandautils.adapters.BasicRecyclerAdapter import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.getActivityOrNull import com.instructure.pandautils.utils.setGone import com.instructure.pandautils.utils.setVisible import com.instructure.pandautils.utils.setupAsBackButton @@ -47,6 +51,11 @@ class UploadStatusSubmissionView(inflater: LayoutInflater, parent: ViewGroup) : private var dialog: AlertDialog? = null + init { + binding.toolbar.applyTopSystemBarInsets() + binding.uploadStatusRecycler.applyBottomSystemBarInsets() + } + private val adapter = UploadRecyclerAdapter(object : UploadListCallback { override fun deleteClicked(position: Int) { consumer?.accept(UploadStatusSubmissionEvent.OnDeleteFile(position)) @@ -63,6 +72,13 @@ class UploadStatusSubmissionView(inflater: LayoutInflater, parent: ViewGroup) : ViewStyler.themeToolbarLight(context as Activity, binding.toolbar) } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + context.getActivityOrNull()?.let { + ViewStyler.setStatusBarLightDelayed(it) + } + } + override fun onConnect(output: Consumer) = with(binding) { toolbar.setupAsBackButton { backPress() } toolbar.title = context.getString(R.string.submission) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadEffectHandler.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadEffectHandler.kt index d806afc883..5038b26674 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadEffectHandler.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadEffectHandler.kt @@ -22,6 +22,7 @@ import android.net.Uri import androidx.core.content.FileProvider import com.instructure.canvasapi2.models.postmodels.FileSubmitObject import com.instructure.canvasapi2.utils.exhaustive +import com.instructure.pandautils.features.file.upload.scanner.DocumentScannerManager import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.FilePrefs import com.instructure.pandautils.utils.FileUploadUtils @@ -50,7 +51,8 @@ import java.io.File // We need a context in this class to register receivers and to access the database class PickerSubmissionUploadEffectHandler( private val context: Context, - private val submissionHelper: SubmissionHelper + private val submissionHelper: SubmissionHelper, + private val documentScannerManager: DocumentScannerManager ) : EffectHandler() { override fun connect(output: Consumer): Connection { @@ -94,8 +96,10 @@ class PickerSubmissionUploadEffectHandler( } else if (it.requestCode == REQUEST_DOCUMENT_SCANNING) { event.remove() - if (it.data != null && it.data?.data != null) { - consumer.accept(PickerSubmissionUploadEvent.OnFileSelected(it.data!!.data!!)) + val scanResult = documentScannerManager.handleScanResultFromIntent(it.data) + val pdfUri = scanResult.pdfUri + if (pdfUri != null) { + consumer.accept(PickerSubmissionUploadEvent.OnFileSelected(pdfUri)) } else { view?.showErrorMessage(R.string.unexpectedErrorOpeningFile) } @@ -115,6 +119,9 @@ class PickerSubmissionUploadEffectHandler( PickerSubmissionUploadEffect.LaunchSelectFile -> { launchSelectFile() } + PickerSubmissionUploadEffect.LaunchScanner -> { + launchScanner() + } is PickerSubmissionUploadEffect.LoadFileContents -> { loadFile(effect.allowedExtensions, effect.uri, context) } @@ -215,6 +222,21 @@ class PickerSubmissionUploadEffectHandler( } } + private fun launchScanner() { + val activity = context.getFragmentActivity() + documentScannerManager.getStartScanIntent(activity) + .addOnSuccessListener { intentSender -> + activity.startIntentSenderForResult( + intentSender, + REQUEST_DOCUMENT_SCANNING, + null, 0, 0, 0 + ) + } + .addOnFailureListener { + view?.showErrorMessage(R.string.unexpectedErrorOpeningFile) + } + } + private fun launchCamera() { // Get camera permission if we need it if (needsPermissions( diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadModels.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadModels.kt index 610aa66f74..8011a08e52 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadModels.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadModels.kt @@ -25,6 +25,7 @@ sealed class PickerSubmissionUploadEvent { object CameraClicked : PickerSubmissionUploadEvent() object GalleryClicked : PickerSubmissionUploadEvent() object SelectFileClicked : PickerSubmissionUploadEvent() + object ScannerClicked : PickerSubmissionUploadEvent() data class OnFileSelected(val uri: Uri) : PickerSubmissionUploadEvent() data class OnFileRemoved(val fileIndex: Int) : PickerSubmissionUploadEvent() data class OnFileAdded(val file: FileSubmitObject?) : PickerSubmissionUploadEvent() @@ -34,6 +35,7 @@ sealed class PickerSubmissionUploadEffect { object LaunchCamera : PickerSubmissionUploadEffect() object LaunchGallery : PickerSubmissionUploadEffect() object LaunchSelectFile : PickerSubmissionUploadEffect() + object LaunchScanner : PickerSubmissionUploadEffect() data class HandleSubmit(val model: PickerSubmissionUploadModel) : PickerSubmissionUploadEffect() data class LoadFileContents(val uri: Uri, val allowedExtensions: List) : PickerSubmissionUploadEffect() @@ -51,7 +53,8 @@ data class PickerSubmissionUploadModel( val files: List = emptyList(), val isLoadingFile: Boolean = false, val attemptId: Long? = null, - val mediaSource: String? = null + val mediaSource: String? = null, + val scannerAvailable: Boolean = false ) enum class PickerSubmissionMode { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadPresenter.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadPresenter.kt index 4d2ab35dea..7a0e01db4c 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadPresenter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadPresenter.kt @@ -62,7 +62,8 @@ object PickerSubmissionUploadPresenter : Presenter): Boolean { @@ -72,4 +73,8 @@ object PickerSubmissionUploadPresenter : Presenter): Boolean { return allowedExtensions.isEmpty() || allowedExtensions.any { it in listOf("png", "jpg", "jpeg", "mp4", "mov", "gif", "tiff", "bmp") } } + + private fun allowsScannedDocuments(allowedExtensions: List): Boolean { + return allowedExtensions.isEmpty() || allowedExtensions.any { it in listOf("pdf") } + } } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadUpdate.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadUpdate.kt index 5be56f5e1b..b171210a0d 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadUpdate.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/PickerSubmissionUploadUpdate.kt @@ -39,6 +39,7 @@ class PickerSubmissionUploadUpdate : PickerSubmissionUploadEvent.CameraClicked -> Next.next(model.copy(mediaSource = "camera"), setOf(PickerSubmissionUploadEffect.LaunchCamera)) PickerSubmissionUploadEvent.GalleryClicked -> Next.next(model.copy(mediaSource = "library"), setOf(PickerSubmissionUploadEffect.LaunchGallery)) PickerSubmissionUploadEvent.SelectFileClicked -> Next.next(model.copy(mediaSource = "files"), setOf(PickerSubmissionUploadEffect.LaunchSelectFile)) + PickerSubmissionUploadEvent.ScannerClicked -> Next.next(model.copy(mediaSource = "scanner"), setOf(PickerSubmissionUploadEffect.LaunchScanner)) is PickerSubmissionUploadEvent.OnFileSelected -> { Next.next( model.copy(isLoadingFile = true), diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/BasePickerSubmissionUploadFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/BasePickerSubmissionUploadFragment.kt index 7d29f79651..04d394d8f4 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/BasePickerSubmissionUploadFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/BasePickerSubmissionUploadFragment.kt @@ -23,6 +23,7 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.pandautils.analytics.SCREEN_VIEW_SUBMISSION_UPLOAD_PICKER import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.features.file.upload.scanner.DocumentScannerManager import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.LongArg import com.instructure.pandautils.utils.NullableParcelableArg @@ -40,6 +41,7 @@ import com.instructure.student.mobius.assignmentDetails.submission.picker.ui.Pic import com.instructure.student.mobius.assignmentDetails.submission.picker.ui.PickerSubmissionUploadFragment.Companion.MEDIA_SOURCE import com.instructure.student.mobius.assignmentDetails.submission.picker.ui.PickerSubmissionUploadFragment.Companion.PICKER_MODE import com.instructure.student.mobius.common.ui.MobiusFragment +import javax.inject.Inject @ScreenView(SCREEN_VIEW_SUBMISSION_UPLOAD_PICKER) abstract class BasePickerSubmissionUploadFragment : @@ -52,6 +54,9 @@ abstract class BasePickerSubmissionUploadFragment : private var attemptId by LongArg(key = Const.SUBMISSION_ATTEMPT) private val initialMediaSource by NullableStringArg(key = MEDIA_SOURCE) + @Inject + lateinit var documentScannerManager: DocumentScannerManager + override fun makeUpdate() = PickerSubmissionUploadUpdate() override fun makeView(inflater: LayoutInflater, parent: ViewGroup) = @@ -68,6 +73,7 @@ abstract class BasePickerSubmissionUploadFragment : mode, mediaUri, attemptId = attemptId.takeIf { it != INVALID_ATTEMPT }, - mediaSource = initialMediaSource + mediaSource = initialMediaSource, + scannerAvailable = documentScannerManager.isDeviceSupported() ) } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadFragment.kt index bf9a72866f..b9db7498ca 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadFragment.kt @@ -25,6 +25,7 @@ import com.instructure.pandautils.utils.isCourseOrGroup import com.instructure.pandautils.utils.makeBundle import com.instructure.pandautils.utils.withArgs import com.instructure.student.mobius.assignmentDetails.submission.picker.PickerSubmissionMode +import com.instructure.pandautils.features.file.upload.scanner.DocumentScannerManager import com.instructure.student.mobius.assignmentDetails.submission.picker.PickerSubmissionUploadEffectHandler import com.instructure.student.mobius.common.ui.SubmissionHelper import dagger.hilt.android.AndroidEntryPoint @@ -36,7 +37,7 @@ class PickerSubmissionUploadFragment : BasePickerSubmissionUploadFragment() { @Inject lateinit var submissionHelper: SubmissionHelper - override fun makeEffectHandler() = PickerSubmissionUploadEffectHandler(requireContext(), submissionHelper) + override fun makeEffectHandler() = PickerSubmissionUploadEffectHandler(requireContext(), submissionHelper, documentScannerManager) companion object { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadView.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadView.kt index 8a5f4cfe74..0dd557ea9f 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadView.kt @@ -18,6 +18,7 @@ package com.instructure.student.mobius.assignmentDetails.submission.picker.ui import android.app.Activity import android.content.Intent +import android.content.res.Configuration import android.net.Uri import android.provider.MediaStore import android.view.LayoutInflater @@ -53,6 +54,8 @@ class PickerSubmissionUploadView(inflater: LayoutInflater, parent: ViewGroup, va init { binding.toolbar.setupAsBackButton { (context as? Activity)?.onBackPressed() } binding.toolbar.title = context.getString(if (mode.isForComment) R.string.commentUpload else R.string.submission) + binding.toolbar.applyTopSystemBarInsets() + binding.sourcesContainer.applyBottomSystemBarInsets() binding.filePickerRecycler.layoutManager = LinearLayoutManager(context) binding.filePickerRecycler.adapter = adapter @@ -60,6 +63,7 @@ class PickerSubmissionUploadView(inflater: LayoutInflater, parent: ViewGroup, va binding.sourceDevice.setOnClickListener { consumer?.accept(PickerSubmissionUploadEvent.SelectFileClicked) } binding.sourceGallery.setOnClickListener { consumer?.accept(PickerSubmissionUploadEvent.GalleryClicked) } + binding.sourceScanner.setOnClickListener { consumer?.accept(PickerSubmissionUploadEvent.ScannerClicked) } } override fun onConnect(output: Consumer) { @@ -80,6 +84,13 @@ class PickerSubmissionUploadView(inflater: LayoutInflater, parent: ViewGroup, va ViewStyler.themeToolbarLight(context as Activity, binding.toolbar) } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + context.getActivityOrNull()?.let { + ViewStyler.setStatusBarLightDelayed(it) + } + } + override fun render(state: PickerSubmissionUploadViewState) = with(binding) { toolbar.menu.findItem(R.id.menuSubmit).isVisible = state.visibilities.submit fileLoading.setVisible(state.visibilities.loading) @@ -116,6 +127,7 @@ class PickerSubmissionUploadView(inflater: LayoutInflater, parent: ViewGroup, va sourceCamera.setVisible(visibilities.sourceCamera) sourceDevice.setVisible(visibilities.sourceFile) sourceGallery.setVisible(visibilities.sourceGallery) + sourceScanner.setVisible(visibilities.sourceScanner) } fun getSelectFileIntent() = Intent(Intent.ACTION_GET_CONTENT).apply { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadViewState.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadViewState.kt index 710d32187e..0cdcfdd89a 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadViewState.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/picker/ui/PickerSubmissionUploadViewState.kt @@ -27,6 +27,7 @@ data class PickerVisibilities( val sourceCamera: Boolean = false, val sourceGallery: Boolean = false, val sourceFile: Boolean = false, + val sourceScanner: Boolean = false, val submit: Boolean = false ) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/text/ui/TextSubmissionUploadView.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/text/ui/TextSubmissionUploadView.kt index 449271fcd1..7151471815 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/text/ui/TextSubmissionUploadView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/text/ui/TextSubmissionUploadView.kt @@ -17,6 +17,7 @@ package com.instructure.student.mobius.assignmentDetails.submission.text.ui import android.app.Activity +import android.content.res.Configuration import android.net.Uri import android.view.LayoutInflater import android.view.ViewGroup @@ -26,6 +27,9 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.pandautils.utils.MediaUploadUtils import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.getActivityOrNull import com.instructure.pandautils.utils.setMenu import com.instructure.pandautils.utils.setVisible import com.instructure.pandautils.utils.setupAsBackButton @@ -51,6 +55,8 @@ class TextSubmissionUploadView(inflater: LayoutInflater, parent: ViewGroup) : init { binding.toolbar.setupAsBackButton { (context as? Activity)?.onBackPressed() } binding.toolbar.title = context.getString(R.string.textEntry) + binding.toolbar.applyTopSystemBarInsets() + binding.rce.applyBottomSystemBarInsets() } override fun onConnect(output: Consumer) = with(binding) { @@ -102,6 +108,13 @@ class TextSubmissionUploadView(inflater: LayoutInflater, parent: ViewGroup) : ViewStyler.themeToolbarLight(context as Activity, binding.toolbar) } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + context.getActivityOrNull()?.let { + ViewStyler.setStatusBarLightDelayed(it) + } + } + fun setInitialSubmissionText(text: String?) { initialText = text binding.rce.setHtml(text ?: "", context.getString(R.string.textEntry), context.getString(R.string.submissionWrite), ThemePrefs.brandColor, ThemePrefs.textButtonColor) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/url/ui/UrlSubmissionUploadView.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/url/ui/UrlSubmissionUploadView.kt index b3e36dd319..e31a5aae66 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/url/ui/UrlSubmissionUploadView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submission/url/ui/UrlSubmissionUploadView.kt @@ -16,9 +16,14 @@ package com.instructure.student.mobius.assignmentDetails.submission.url.ui import android.app.Activity +import android.content.res.Configuration import android.view.LayoutInflater import android.view.ViewGroup import android.webkit.WebViewClient +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.getActivityOrNull import com.instructure.pandautils.utils.onChangeDebounce import com.instructure.pandautils.utils.setMenu import com.instructure.pandautils.utils.setVisible @@ -37,6 +42,8 @@ class UrlSubmissionUploadView(inflater: LayoutInflater, parent: ViewGroup) : Mob init { binding.toolbar.setupAsBackButton { (context as? Activity)?.onBackPressed() } binding.toolbar.title = context.getString(R.string.websiteUrl) + binding.toolbar.applyTopSystemBarInsets() + binding.urlPreviewWebView.applyBottomSystemBarInsets() binding.urlPreviewWebView.webViewClient = WebViewClient() binding.urlPreviewWebView.settings.javaScriptEnabled = true @@ -68,7 +75,17 @@ class UrlSubmissionUploadView(inflater: LayoutInflater, parent: ViewGroup) : Mob } override fun onDispose() {} - override fun applyTheme() {} + + override fun applyTheme() { + ViewStyler.themeToolbarLight(context as Activity, binding.toolbar) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + context.getActivityOrNull()?.let { + ViewStyler.setStatusBarLightDelayed(it) + } + } fun showPreviewUrl(url: String) { binding.urlPreviewWebView.setVisible() diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/PdfStudentSubmissionView.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/PdfStudentSubmissionView.kt index 33915716ac..8bb0699361 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/PdfStudentSubmissionView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/PdfStudentSubmissionView.kt @@ -48,8 +48,9 @@ import com.instructure.student.R import com.instructure.student.databinding.ViewPdfStudentSubmissionBinding import com.instructure.student.router.RouteMatcher import com.pspdfkit.preferences.PSPDFKitPreferences +import com.pspdfkit.ui.annotations.OnAnnotationCreationModeChangeListener +import com.pspdfkit.ui.annotations.OnAnnotationEditingModeChangeListener import com.pspdfkit.ui.inspector.PropertyInspectorCoordinatorLayout -import com.pspdfkit.ui.special_mode.manager.AnnotationManager import com.pspdfkit.ui.toolbar.ToolbarCoordinatorLayout import kotlinx.coroutines.Job import okhttp3.ResponseBody @@ -67,7 +68,7 @@ class PdfStudentSubmissionView( private val studentAnnotationView: Boolean = false, ) : PdfSubmissionView( activity, studentAnnotationView, courseId -), AnnotationManager.OnAnnotationCreationModeChangeListener, AnnotationManager.OnAnnotationEditingModeChangeListener { +), OnAnnotationCreationModeChangeListener, OnAnnotationEditingModeChangeListener { private val binding: ViewPdfStudentSubmissionBinding diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricDescriptionFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricDescriptionFragment.kt index 0e12c066bd..0c6f4c85a2 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricDescriptionFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricDescriptionFragment.kt @@ -16,6 +16,7 @@ */ package com.instructure.student.mobius.assignmentDetails.submissionDetails.drawer.rubric.ui +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -26,7 +27,15 @@ import com.instructure.pandautils.base.BaseCanvasDialogFragment import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.interactions.router.Route import com.instructure.pandautils.binding.viewBinding -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.StringArg +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.setGone +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.setupAsBackButton +import com.instructure.pandautils.utils.withArgs import com.instructure.pandautils.views.CanvasWebView import com.instructure.student.R import com.instructure.student.activity.InternalWebViewActivity @@ -53,6 +62,7 @@ class SubmissionRubricDescriptionFragment : BaseCanvasDialogFragment() { with (binding) { toolbar.title = title toolbar.setupAsBackButton(this@SubmissionRubricDescriptionFragment) + toolbar.applyTopSystemBarInsets() ViewStyler.themeToolbarLight(requireActivity(), toolbar) // Show progress bar while loading description @@ -92,11 +102,18 @@ class SubmissionRubricDescriptionFragment : BaseCanvasDialogFragment() { // make the WebView background transparent webView.setBackgroundResource(android.R.color.transparent) + webView.applyBottomSystemBarInsets() + // Load description webView.loadHtml(description, title) } } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ViewStyler.setStatusBarLightDelayed(requireActivity()) + } + companion object { fun makeRoute(title: String, description: String): Route { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsView.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsView.kt index 5c6e57e0fb..cdbbbb2247 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsView.kt @@ -42,6 +42,8 @@ import com.instructure.pandautils.binding.BindableSpinnerAdapter import com.instructure.pandautils.features.assignmentdetails.AssignmentDetailsAttemptItemViewModel import com.instructure.pandautils.features.assignmentdetails.AssignmentDetailsAttemptViewData import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyImeAndSystemBarMargin +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.asStateList import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.hideKeyboard @@ -110,6 +112,7 @@ class SubmissionDetailsView( } init { + binding.toolbar.applyTopSystemBarInsets() binding.toolbar.setupAsBackButton { activity.onBackPressed() } binding.retryButton.onClick { consumer?.accept(SubmissionDetailsEvent.RefreshRequested) } binding.drawerViewPager.offscreenPageLimit = 3 @@ -117,6 +120,8 @@ class SubmissionDetailsView( configureDrawerTabLayout() configureSlidingPanelHeight() + binding.slidingUpPanelLayout.applyImeAndSystemBarMargin() + if (isAccessibilityEnabled(context)) { binding.slidingUpPanelLayout.anchorPoint = 1.0f } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/common/ui/MobiusFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/common/ui/MobiusFragment.kt index 6b56a89e2c..f47f659810 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/common/ui/MobiusFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/common/ui/MobiusFragment.kt @@ -18,6 +18,7 @@ package com.instructure.student.mobius.common.ui import android.app.Activity import android.content.Context +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -151,6 +152,11 @@ abstract class MobiusFragment : Update, Init, @@ -230,6 +236,8 @@ abstract class MobiusView(inflater: Lay fun releaseBinding() { _binding = null } + + open fun onConfigurationChanged(newConfig: Configuration) = Unit } interface Presenter { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_details/ui/ConferenceDetailsView.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_details/ui/ConferenceDetailsView.kt index 525d5391aa..a472dcb972 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_details/ui/ConferenceDetailsView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_details/ui/ConferenceDetailsView.kt @@ -24,6 +24,10 @@ import android.view.View import android.view.ViewGroup import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.models.CanvasContext import com.instructure.pandautils.utils.ViewStyler @@ -50,6 +54,24 @@ class ConferenceDetailsView(val canvasContext: CanvasContext, inflater: LayoutIn init { binding.toolbar.setupAsBackButton { (context as? Activity)?.onBackPressed() } binding.toolbar.subtitle = canvasContext.name + + // Apply top system bar insets for edge-to-edge support + ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(top = systemBars.top) + insets + } + ViewCompat.requestApplyInsets(binding.toolbar) + + // Apply bottom system bar insets to join container using margin + ViewCompat.setOnApplyWindowInsetsListener(binding.joinContainer) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updateLayoutParams { + bottomMargin = systemBars.bottom + } + insets + } + ViewCompat.requestApplyInsets(binding.joinContainer) } override fun applyTheme() { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListView.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListView.kt index 7bb9d6daf8..ae5c5aba7a 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListView.kt @@ -29,6 +29,8 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Conference import com.instructure.canvasapi2.utils.exhaustive import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.asChooserExcludingInstructure import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.items @@ -59,6 +61,8 @@ class ConferenceListView( init { binding.toolbar.setupAsBackButton { (context as? Activity)?.onBackPressed() } binding.toolbar.subtitle = canvasContext.name + binding.toolbar.applyTopSystemBarInsets() + binding.recyclerView.applyBottomSystemBarInsets() // Set up menu with(binding.toolbar.menu.add(0, R.id.openExternallyButton, 0, R.string.openInBrowser)) { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/elementary/ElementaryDashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/elementary/ElementaryDashboardFragment.kt index a9ae53a5a2..95c167dd8c 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/elementary/ElementaryDashboardFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/elementary/ElementaryDashboardFragment.kt @@ -20,6 +20,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.google.android.material.tabs.TabLayout import com.instructure.canvasapi2.models.CanvasContext import com.instructure.interactions.router.Route @@ -33,6 +35,7 @@ import com.instructure.pandautils.features.elementary.resources.ResourcesFragmen import com.instructure.pandautils.features.elementary.schedule.pager.SchedulePagerFragment import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.isTablet import com.instructure.pandautils.utils.makeBundle import com.instructure.student.R @@ -60,6 +63,7 @@ class ElementaryDashboardFragment : ParentFragment() { override fun applyTheme() { binding.toolbar.title = title() + binding.toolbar.applyTopSystemBarInsets() navigation?.attachNavigationDrawer(this, binding.toolbar) } @@ -81,6 +85,24 @@ class ElementaryDashboardFragment : ParentFragment() { super.onViewCreated(view, savedInstanceState) dashboardPager.adapter = ElementaryDashboardPagerAdapter(fragments, childFragmentManager) + + // Consume bottom insets so child fragments don't apply them + // The Canvas bottom navigation bar handles bottom spacing at the activity level + ViewCompat.setOnApplyWindowInsetsListener(dashboardPager) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + WindowInsetsCompat.Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + androidx.core.graphics.Insets.of( + navigationBars.left, + navigationBars.top, + navigationBars.right, + 0 // Consume bottom insets + ) + ) + .build() + } + dashboardTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabReselected(tab: TabLayout.Tab?) = Unit diff --git a/apps/student/src/main/java/com/instructure/student/mobius/settings/pairobserver/ui/PairObserverView.kt b/apps/student/src/main/java/com/instructure/student/mobius/settings/pairobserver/ui/PairObserverView.kt index 3595c5e834..a859eade63 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/settings/pairobserver/ui/PairObserverView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/settings/pairobserver/ui/PairObserverView.kt @@ -42,6 +42,7 @@ class PairObserverView(inflater: LayoutInflater, parent: ViewGroup) : init { binding.toolbar.setupAsBackButton { (context as? Activity)?.onBackPressed() } + binding.toolbar.applyTopSystemBarInsets() } override fun applyTheme() { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/syllabus/ui/SyllabusView.kt b/apps/student/src/main/java/com/instructure/student/mobius/syllabus/ui/SyllabusView.kt index fecfb4604b..98eb5a379c 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/syllabus/ui/SyllabusView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/syllabus/ui/SyllabusView.kt @@ -19,6 +19,8 @@ package com.instructure.student.mobius.syllabus.ui import android.app.Activity import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.FragmentActivity import com.google.android.material.tabs.TabLayout import com.instructure.canvasapi2.models.CanvasContext @@ -75,12 +77,57 @@ class SyllabusView( binding.toolbar.title = context.getString(com.instructure.pandares.R.string.syllabus) binding.toolbar.subtitle = canvasContext.name + setupWindowInsets() + adapter = SyllabusTabAdapter(activity, canvasContext, getTabTitles()) binding.syllabusPager.adapter = adapter binding.syllabusTabLayout.setupWithViewPager(binding.syllabusPager, true) } + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { view, insets -> + val statusBars = insets.getInsets(WindowInsetsCompat.Type.statusBars()) + view.setPadding( + view.paddingLeft, + statusBars.top, + view.paddingRight, + view.paddingBottom + ) + insets + } + } + + private fun setupRecyclerViewInsets() { + eventsBinding?.syllabusEventsRecycler?.let { recyclerView -> + ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding( + view.paddingLeft, + view.paddingTop, + view.paddingRight, + systemBars.bottom + ) + insets + } + } + } + + private fun setupWebViewInsets() { + webviewBinding?.syllabusScrollView?.let { scrollView -> + ViewCompat.setOnApplyWindowInsetsListener(scrollView) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding( + view.paddingLeft, + view.paddingTop, + view.paddingRight, + systemBars.bottom + ) + insets + } + } + } + override fun applyTheme() { ViewStyler.themeToolbarColored(context as Activity, binding.toolbar, canvasContext) binding.syllabusTabLayout.setBackgroundColor(canvasContext.color) @@ -98,6 +145,8 @@ class SyllabusView( override fun render(state: SyllabusViewState) { webviewBinding = adapter.webviewBinding eventsBinding = adapter.eventsBinding + setupRecyclerViewInsets() + setupWebViewInsets() when (state) { SyllabusViewState.Loading -> { binding.swipeRefreshLayout.isRefreshing = true diff --git a/apps/student/src/main/java/com/instructure/student/util/DefaultAppShortcutManager.kt b/apps/student/src/main/java/com/instructure/student/util/DefaultAppShortcutManager.kt index e7fc5117f7..24d1135d35 100644 --- a/apps/student/src/main/java/com/instructure/student/util/DefaultAppShortcutManager.kt +++ b/apps/student/src/main/java/com/instructure/student/util/DefaultAppShortcutManager.kt @@ -53,8 +53,8 @@ class DefaultAppShortcutManager : AppShortcutManager { todoIntent.action = AppShortcutManager.ACTION_APP_SHORTCUT todoIntent.putExtra(AppShortcutManager.APP_SHORTCUT_PLACEMENT, AppShortcutManager.APP_SHORTCUT_TODO) val shortcutTodo = createShortcut(context, AppShortcutManager.APP_SHORTCUT_TODO, - context.getString(R.string.toDoList), - context.getString(R.string.toDoList), + context.getString(R.string.toDoListNew), + context.getString(R.string.toDoListNew), R.mipmap.ic_shortcut_todo, todoIntent) diff --git a/apps/student/src/main/java/com/instructure/student/util/FileUtils.kt b/apps/student/src/main/java/com/instructure/student/util/FileUtils.kt index 1e34d0599f..647d7adba7 100644 --- a/apps/student/src/main/java/com/instructure/student/util/FileUtils.kt +++ b/apps/student/src/main/java/com/instructure/student/util/FileUtils.kt @@ -21,11 +21,12 @@ import android.content.Context import android.net.Uri import androidx.annotation.IntegerRes import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.features.shareextension.ShareFileSubmissionTarget import com.instructure.pandautils.loaders.OpenMediaAsyncTaskLoader +import com.instructure.pandautils.utils.Const import com.instructure.student.R import com.instructure.student.activity.CandroidPSPDFActivity -import com.instructure.pandautils.features.shareextension.ShareFileSubmissionTarget -import com.pspdfkit.PSPDFKit +import com.pspdfkit.Nutrient import com.pspdfkit.annotations.AnnotationType import com.pspdfkit.configuration.activity.PdfActivityConfiguration import com.pspdfkit.configuration.activity.ThumbnailBarMode @@ -73,6 +74,7 @@ object FileUtils { pspdfActivityConfiguration = PdfActivityConfiguration.Builder(context) .scrollDirection(PageScrollDirection.HORIZONTAL) .setThumbnailBarMode(ThumbnailBarMode.THUMBNAIL_BAR_MODE_PINNED) + .contentEditingEnabled(false) .fitMode(PageFitMode.FIT_TO_WIDTH) .build() } else { @@ -82,17 +84,18 @@ object FileUtils { .setDocumentInfoViewSeparated(false) .enabledAnnotationTools(annotationCreationList) .editableAnnotationTypes(annotationEditList) + .contentEditingEnabled(false) .fitMode(PageFitMode.FIT_TO_WIDTH) .build() } - if (PSPDFKit.isOpenableUri(context, uri)) { + if (Nutrient.isOpenableUri(context, uri)) { val intent = PdfActivityIntentBuilder .fromUri(context, uri) .configuration(pspdfActivityConfiguration) .activityClass(CandroidPSPDFActivity::class.java) .build() - intent.putExtra(com.instructure.pandautils.utils.Const.SUBMISSION_TARGET, submissionTarget) + intent.putExtra(Const.SUBMISSION_TARGET, submissionTarget) context.startActivity(intent) } else { //If we still can't open this PDF, we will then attempt to pass it off to the user's pdfviewer diff --git a/apps/student/src/main/java/com/instructure/student/widget/grades/courseselector/CourseSelectorActivity.kt b/apps/student/src/main/java/com/instructure/student/widget/grades/courseselector/CourseSelectorActivity.kt index ab5eb0fa55..a2072fe0a8 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/grades/courseselector/CourseSelectorActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/grades/courseselector/CourseSelectorActivity.kt @@ -21,6 +21,7 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Scaffold import androidx.compose.runtime.collectAsState @@ -63,6 +64,7 @@ class CourseSelectorActivity : BaseCanvasActivity() { setContent { val uiState by viewModel.uiState.collectAsState() Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { CanvasThemedAppBar( title = stringResource(R.string.selectCourse), diff --git a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidget.kt b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidget.kt index 9de5e83aa2..1782a017b9 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidget.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidget.kt @@ -127,7 +127,7 @@ class ToDoWidget : GlanceAppWidget() { WidgetFloatingActionButton( alignment = Alignment.TopEnd, imageRes = R.drawable.ic_canvas_logo_student, - contentDescriptionRes = R.string.a11y_widgetToDoOpenToDoList, + contentDescriptionRes = R.string.a11y_widgetToDoOpenToDoListNew, backgroundColor = WidgetColors.textDanger, tintColor = WidgetColors.textLightest, onClickAction = @@ -161,7 +161,7 @@ class ToDoWidget : GlanceAppWidget() { WidgetFloatingActionButton( alignment = Alignment.BottomEnd, imageRes = R.drawable.ic_add_lined, - contentDescriptionRes = R.string.a11y_widgetToDoCreateNewToDo, + contentDescriptionRes = R.string.a11y_widgetToDoCreateNewToDoNew, backgroundColor = ColorProvider( color = Color(color = ThemePrefs.buttonColor) ), diff --git a/apps/student/src/main/res/layout/activity_navigation.xml b/apps/student/src/main/res/layout/activity_navigation.xml index cdbdefd661..0c007a90f3 100644 --- a/apps/student/src/main/res/layout/activity_navigation.xml +++ b/apps/student/src/main/res/layout/activity_navigation.xml @@ -57,16 +57,24 @@ android:layout_height="1dp" android:background="@color/backgroundLight" /> - + android:layout_height="56dp" + android:background="@color/backgroundLightestElevated"> + + + + diff --git a/apps/student/src/main/res/layout/course_discussion_topic.xml b/apps/student/src/main/res/layout/course_discussion_topic.xml index cc09febe77..821d9032c9 100644 --- a/apps/student/src/main/res/layout/course_discussion_topic.xml +++ b/apps/student/src/main/res/layout/course_discussion_topic.xml @@ -28,7 +28,8 @@ diff --git a/apps/student/src/main/res/layout/fragment_annotation_submission_upload.xml b/apps/student/src/main/res/layout/fragment_annotation_submission_upload.xml index 93d623aa1e..7bbf44ed92 100644 --- a/apps/student/src/main/res/layout/fragment_annotation_submission_upload.xml +++ b/apps/student/src/main/res/layout/fragment_annotation_submission_upload.xml @@ -30,7 +30,8 @@ diff --git a/apps/student/src/main/res/layout/fragment_assignment_basic.xml b/apps/student/src/main/res/layout/fragment_assignment_basic.xml index 4ba981c087..28168b9ad8 100644 --- a/apps/student/src/main/res/layout/fragment_assignment_basic.xml +++ b/apps/student/src/main/res/layout/fragment_assignment_basic.xml @@ -26,7 +26,8 @@ @@ -155,6 +156,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:cacheColorHint="@android:color/transparent" + android:clipToPadding="false" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> diff --git a/apps/student/src/main/res/layout/fragment_course_grades.xml b/apps/student/src/main/res/layout/fragment_course_grades.xml index 36d85c1c43..76f9cd4027 100644 --- a/apps/student/src/main/res/layout/fragment_course_grades.xml +++ b/apps/student/src/main/res/layout/fragment_course_grades.xml @@ -26,7 +26,8 @@ diff --git a/apps/student/src/main/res/layout/fragment_discussions_details.xml b/apps/student/src/main/res/layout/fragment_discussions_details.xml index 4c86a44f4e..78eade94fd 100644 --- a/apps/student/src/main/res/layout/fragment_discussions_details.xml +++ b/apps/student/src/main/res/layout/fragment_discussions_details.xml @@ -33,6 +33,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize" android:elevation="6dp" tools:background="#00bcd5" tools:ignore="UnusedAttribute" /> diff --git a/apps/student/src/main/res/layout/fragment_edit_page.xml b/apps/student/src/main/res/layout/fragment_edit_page.xml index 88a4f2fb50..cc046ac2fe 100644 --- a/apps/student/src/main/res/layout/fragment_edit_page.xml +++ b/apps/student/src/main/res/layout/fragment_edit_page.xml @@ -23,6 +23,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize" android:elevation="2dp"> + + + + + + + + diff --git a/apps/student/src/main/res/layout/fragment_syllabus.xml b/apps/student/src/main/res/layout/fragment_syllabus.xml index e854156095..dcfb8b597a 100644 --- a/apps/student/src/main/res/layout/fragment_syllabus.xml +++ b/apps/student/src/main/res/layout/fragment_syllabus.xml @@ -27,6 +27,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?attr/actionBarSize" android:elevation="6dp" tools:background="#00bcd5" tools:navigationIcon="@drawable/ic_back_arrow" diff --git a/apps/student/src/main/res/layout/fragment_syllabus_events.xml b/apps/student/src/main/res/layout/fragment_syllabus_events.xml index 413be35314..3a9d5f98f1 100644 --- a/apps/student/src/main/res/layout/fragment_syllabus_events.xml +++ b/apps/student/src/main/res/layout/fragment_syllabus_events.xml @@ -24,6 +24,7 @@ android:id="@+id/syllabusEventsRecycler" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipToPadding="false" tools:listitem="@layout/viewholder_card_generic" tools:visibility="gone" /> diff --git a/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml b/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml index c42c459331..ee4ec05891 100644 --- a/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml +++ b/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml @@ -24,6 +24,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize" android:elevation="2dp" tools:ignore="UnusedAttribute"/> diff --git a/apps/student/src/main/res/layout/fragment_webview.xml b/apps/student/src/main/res/layout/fragment_webview.xml index 0dd387ac12..f09362c697 100644 --- a/apps/student/src/main/res/layout/fragment_webview.xml +++ b/apps/student/src/main/res/layout/fragment_webview.xml @@ -27,7 +27,8 @@ diff --git a/apps/student/src/main/res/menu/bottom_bar_menu_elementary.xml b/apps/student/src/main/res/menu/bottom_bar_menu_elementary.xml index d4885b30ed..b34d19fd04 100644 --- a/apps/student/src/main/res/menu/bottom_bar_menu_elementary.xml +++ b/apps/student/src/main/res/menu/bottom_bar_menu_elementary.xml @@ -39,7 +39,7 @@ android:id="@+id/bottomNavigationToDo" android:enabled="true" android:icon="@drawable/bottom_nav_todo" - android:title="@string/Todo" + android:title="@string/TodoNew" app:showAsAction="always" tools:ignore="AlwaysShowAction"/> diff --git a/apps/student/src/main/res/values-night/styles.xml b/apps/student/src/main/res/values-night/styles.xml index 5dfac1c73f..eb2fb7e728 100644 --- a/apps/student/src/main/res/values-night/styles.xml +++ b/apps/student/src/main/res/values-night/styles.xml @@ -38,7 +38,6 @@ @style/PSPDFAnnotationCreationToolbarIconsStyle @style/PSPDFKitActionBarIconsStyle @style/ContextualToolbarStyle - true \ No newline at end of file diff --git a/apps/student/src/main/res/values/styles.xml b/apps/student/src/main/res/values/styles.xml index 93466a1d23..0c05c17652 100644 --- a/apps/student/src/main/res/values/styles.xml +++ b/apps/student/src/main/res/values/styles.xml @@ -25,7 +25,6 @@ true true true - true @@ -52,7 +50,6 @@ @style/PSPDFAnnotationCreationToolbarIconsStyle @style/PSPDFKitActionBarIconsStyle @style/ContextualToolbarStyle - true diff --git a/apps/student/src/main/res/values/themes_canvastheme.xml b/apps/student/src/main/res/values/themes_canvastheme.xml index bf1dbf3405..d3af28925c 100755 --- a/apps/student/src/main/res/values/themes_canvastheme.xml +++ b/apps/student/src/main/res/values/themes_canvastheme.xml @@ -26,6 +26,7 @@ false true @color/darkStatusBarColor + @android:color/transparent @color/backgroundLightest true true @@ -45,7 +46,6 @@ @style/AnnotationNoteHinter @color/backgroundLight @style/DatePickerStyle - true diff --git a/apps/student/src/main/res/xml/todo_widget_info.xml b/apps/student/src/main/res/xml/todo_widget_info.xml index 02ea1b7c5f..cb23e17a00 100644 --- a/apps/student/src/main/res/xml/todo_widget_info.xml +++ b/apps/student/src/main/res/xml/todo_widget_info.xml @@ -18,7 +18,7 @@ + + diff --git a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submission/PickerSubmissionUploadEffectHandlerTest.kt b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submission/PickerSubmissionUploadEffectHandlerTest.kt index ce1fe893fe..3b196fbcae 100644 --- a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submission/PickerSubmissionUploadEffectHandlerTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submission/PickerSubmissionUploadEffectHandlerTest.kt @@ -23,6 +23,7 @@ import androidx.core.content.FileProvider import androidx.fragment.app.FragmentActivity import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.postmodels.FileSubmitObject +import com.instructure.pandautils.features.file.upload.scanner.DocumentScannerManager import com.instructure.pandautils.utils.ActivityResult import com.instructure.pandautils.utils.FilePrefs import com.instructure.pandautils.utils.FileUploadUtils @@ -64,7 +65,8 @@ class PickerSubmissionUploadEffectHandlerTest : Assert() { private val view: PickerSubmissionUploadView = mockk(relaxed = true) private val eventConsumer: Consumer = mockk(relaxed = true) private val submissionHelper: SubmissionHelper = mockk(relaxed = true) - private val effectHandler = PickerSubmissionUploadEffectHandler(context, submissionHelper) + private val documentScannerManager: DocumentScannerManager = mockk(relaxed = true) + private val effectHandler = PickerSubmissionUploadEffectHandler(context, submissionHelper, documentScannerManager) private val connection = effectHandler.connect(eventConsumer) @ExperimentalCoroutinesApi diff --git a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submission/PickerSubmissionUploadUpdateTest.kt b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submission/PickerSubmissionUploadUpdateTest.kt index 94c79d1652..cb7141df87 100644 --- a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submission/PickerSubmissionUploadUpdateTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submission/PickerSubmissionUploadUpdateTest.kt @@ -168,6 +168,22 @@ class PickerSubmissionUploadUpdateTest : Assert() { ) } + @Test + fun `ScannerClicked event results in LaunchScanner effect`() { + val expectedModel = initModel.copy(mediaSource = "scanner") + updateSpec + .given(initModel) + .whenEvent(PickerSubmissionUploadEvent.ScannerClicked) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + matchesEffects( + PickerSubmissionUploadEffect.LaunchScanner + ) + ) + ) + } + @Test fun `OnFileAdded event results in model change to files`() { val startModel = initModel.copy(files = emptyList(), isLoadingFile = true) diff --git a/apps/student/src/test/java/com/instructure/student/test/util/ModuleUtilityTest.kt b/apps/student/src/test/java/com/instructure/student/test/util/ModuleUtilityTest.kt index d4c052a9ea..5d9de87bec 100644 --- a/apps/student/src/test/java/com/instructure/student/test/util/ModuleUtilityTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/util/ModuleUtilityTest.kt @@ -330,6 +330,7 @@ class ModuleUtilityTest : TestCase() { val course = Course() val expectedBundle = Bundle() expectedBundle.putParcelable(Const.CANVAS_CONTEXT, course) + expectedBundle.putBoolean("isInModulesPager", false) expectedBundle.putLong(DiscussionDetailsFragment.DISCUSSION_TOPIC_HEADER_ID, 123456789) val parentFragment = callGetFragment(moduleItem, course, null) assertNotNull(parentFragment) diff --git a/apps/student/src/test/java/com/instructure/student/widget/todo/ToDoWidgetUpdaterTest.kt b/apps/student/src/test/java/com/instructure/student/widget/todo/ToDoWidgetUpdaterTest.kt index 7eb273ea72..011f095036 100644 --- a/apps/student/src/test/java/com/instructure/student/widget/todo/ToDoWidgetUpdaterTest.kt +++ b/apps/student/src/test/java/com/instructure/student/widget/todo/ToDoWidgetUpdaterTest.kt @@ -71,7 +71,7 @@ class ToDoWidgetUpdaterTest { ContextKeeper.appContext = mockk(relaxed = true) mockkObject(DateHelper) every { DateHelper.getPreferredTimeFormat(any()) } returns SimpleDateFormat("HH:mm", Locale.getDefault()) - every { context.getString(R.string.userCalendarToDo) } returns "To Do" + every { context.getString(R.string.userCalendarToDoNew) } returns "To Do" every { context.getString(R.string.widgetAllDay) } returns "All day" // Set up default mocks diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index d0af2eb124..a1c38e1e1c 100644 --- a/apps/teacher/build.gradle +++ b/apps/teacher/build.gradle @@ -51,8 +51,8 @@ android { defaultConfig { minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 88 - versionName = '2.4.0' + versionCode = 90 + versionName = '2.5.1' vectorDrawables.useSupportLibrary = true testInstrumentationRunner 'com.instructure.teacher.espresso.TeacherHiltTestRunner' testInstrumentationRunnerArguments disableAnalytics: 'true' diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/DiscussionsE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/DiscussionsE2ETest.kt index 9ef7a23056..ff63057fcd 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/DiscussionsE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/DiscussionsE2ETest.kt @@ -20,19 +20,25 @@ import android.util.Log import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority +import com.instructure.canvas.espresso.SecondaryFeatureCategory import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E +import com.instructure.canvas.espresso.pressBackButton import com.instructure.dataseeding.api.DiscussionTopicsApi -import com.instructure.teacher.ui.utils.TeacherTest +import com.instructure.espresso.getCustomDateCalendar +import com.instructure.teacher.ui.utils.TeacherComposeTest import com.instructure.teacher.ui.utils.extensions.seedData import com.instructure.teacher.ui.utils.extensions.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test import java.lang.Thread.sleep +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone @HiltAndroidTest -class DiscussionsE2ETest : TeacherTest() { +class DiscussionsE2ETest : TeacherComposeTest() { override fun displaysPageObjects() = Unit @@ -196,4 +202,107 @@ class DiscussionsE2ETest : TeacherTest() { discussionsListPage.assertDiscussionCount(1) discussionsListPage.assertHasDiscussion(discussion3.title) } + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DISCUSSIONS, TestCategory.E2E, SecondaryFeatureCategory.DISCUSSION_CHECKPOINTS) + fun testDiscussionCheckpointsCalendarE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + val discussionWithCheckpointsTitle = "Test Discussion with Checkpoints" + val assignmentName = "Test Assignment with Checkpoints" + + Log.d(PREPARATION_TAG, "Convert dates to match with different formats in different screens (Calendar, Assignment Details)") + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val calendarDisplayFormat = SimpleDateFormat(" MMM d 'at' h:mm a", Locale.US) + val replyToTopicCalendar = getCustomDateCalendar(2) + val replyToEntryCalendar = getCustomDateCalendar(4) + val replyToTopicDueDate = dateFormat.format(replyToTopicCalendar.time) + val replyToEntryDueDate = dateFormat.format(replyToEntryCalendar.time) + + Log.d(PREPARATION_TAG, "Seed a discussion topic with checkpoints for '${course.name}' course.") + DiscussionTopicsApi.createDiscussionTopicWithCheckpoints(course.id, teacher.token, discussionWithCheckpointsTitle, assignmentName, replyToTopicDueDate, replyToEntryDueDate) + + val convertedReplyToTopicDueDate = calendarDisplayFormat.format(replyToTopicCalendar.time) + val convertedReplyToEntryDueDate = calendarDisplayFormat.format(replyToEntryCalendar.time) + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Click on the 'Calendar' bottom menu to navigate to the Calendar page.") + dashboardPage.openCalendar() + + Log.d(STEP_TAG, "Navigate forward 2 days to the 'Reply to Topic' checkpoint due date.") + calendarScreenPage.swipeEventsLeft(2) + + Log.d(ASSERTION_TAG, "Assert that the '$discussionWithCheckpointsTitle Reply to Topic' checkpoint is displayed on its due date on the Calendar Page.") + calendarScreenPage.assertItemDetails("$discussionWithCheckpointsTitle Reply to Topic", course.name, "Due$convertedReplyToTopicDueDate") + + Log.d(STEP_TAG, "Select the '$discussionWithCheckpointsTitle Reply to Topic' event and navigate back to the Calendar Page.") + calendarScreenPage.clickOnItem("$discussionWithCheckpointsTitle Reply to Topic") + + Log.d(ASSERTION_TAG, "Assert that the assignment details page is displayed with the correct assignment name.") + assignmentDetailsPage.waitForRender() + assignmentDetailsPage.assertAssignmentName(discussionWithCheckpointsTitle) + + Log.d(STEP_TAG, "Click on the 'Due Dates' section to navigate to the Due Dates page.") + assignmentDetailsPage.openDueDatesPage() + + Log.d(ASSERTION_TAG, "Assert that 2 due dates are visible on the Due Dates page.") + assignmentDueDatesPage.assertDueDatesCount(2) + + Log.d(ASSERTION_TAG, "Assert first due date is with date '$convertedReplyToTopicDueDate'.") + assignmentDueDatesPage.assertDueDateTime(convertedReplyToTopicDueDate) + + Log.d(ASSERTION_TAG, "Assert second due date is with date '$convertedReplyToEntryDueDate'.") + assignmentDueDatesPage.assertDueDateTime(convertedReplyToEntryDueDate) + + Log.d(STEP_TAG, "Navigate back to the Calendar Page.") + pressBackButton(2) + + Log.d(STEP_TAG, "Navigate forward 2 more days to the 'Required Replies' checkpoint due date.") + calendarScreenPage.swipeEventsLeft(2) + + Log.d(ASSERTION_TAG, "Assert that the '$discussionWithCheckpointsTitle Required Replies (2)' checkpoint is displayed on its due date on the Calendar Page.") + calendarScreenPage.assertItemDetails("$discussionWithCheckpointsTitle Required Replies (2)", course.name, "Due$convertedReplyToEntryDueDate") + + Log.d(STEP_TAG, "Select the '$discussionWithCheckpointsTitle Required Replies (2)' event and navigate back to the Calendar Page.") + calendarScreenPage.clickOnItem("$discussionWithCheckpointsTitle Required Replies (2)") + + Log.d(ASSERTION_TAG, "Assert that the assignment details page is displayed with the correct assignment name.") + assignmentDetailsPage.waitForRender() + assignmentDetailsPage.assertAssignmentName(discussionWithCheckpointsTitle) + + Log.d(ASSERTION_TAG, "Assert that the assignment details page is displayed with the correct assignment name.") + assignmentDetailsPage.waitForRender() + assignmentDetailsPage.assertAssignmentName(discussionWithCheckpointsTitle) + + Log.d(STEP_TAG, "Click on the 'Due Dates' section to navigate to the Due Dates page.") + assignmentDetailsPage.openDueDatesPage() + + Log.d(ASSERTION_TAG, "Assert that 2 due dates are visible on the Due Dates page.") + assignmentDueDatesPage.assertDueDatesCount(2) + + Log.d(ASSERTION_TAG, "Assert first due date is with date '$convertedReplyToTopicDueDate'.") + assignmentDueDatesPage.assertDueDateTime(convertedReplyToTopicDueDate) + + Log.d(ASSERTION_TAG, "Assert second due date is with date '$convertedReplyToEntryDueDate'.") + assignmentDueDatesPage.assertDueDateTime(convertedReplyToEntryDueDate) + + Log.d(STEP_TAG, "Navigate back to the Calendar Page.") + pressBackButton(2) + + Log.d(STEP_TAG, "Navigate back 2 days to the 'Reply to Topic' checkpoint due date.") + calendarScreenPage.swipeEventsRight(2) + + Log.d(ASSERTION_TAG, "Assert that the '$discussionWithCheckpointsTitle Reply to Topic' checkpoint is displayed on its due date on the Calendar Page.") + calendarScreenPage.assertItemDetails("$discussionWithCheckpointsTitle Reply to Topic", course.name, "Due$convertedReplyToTopicDueDate") + } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/FilesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/FilesE2ETest.kt index 2e2c61adc4..0e6e422a88 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/FilesE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/FilesE2ETest.kt @@ -20,8 +20,11 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.pdf.PdfDocument import android.os.Environment +import android.os.SystemClock.sleep import android.util.Log +import androidx.media3.ui.R import androidx.test.espresso.Espresso +import androidx.test.espresso.intent.Intents import androidx.test.uiautomator.UiSelector import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority @@ -40,6 +43,8 @@ import com.instructure.dataseeding.api.SubmissionsApi import com.instructure.dataseeding.model.FileUploadType import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.Randomizer +import com.instructure.espresso.getVideoPosition +import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.espresso.triggerWorkManagerJobs import com.instructure.teacher.ui.utils.TeacherComposeTest import com.instructure.teacher.ui.utils.extensions.seedData @@ -350,4 +355,148 @@ class FilesE2ETest: TeacherComposeTest() { speedGraderPage.assertCommentsLabelDisplayed(1) } + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.FILES, TestCategory.E2E) + fun testVideoFileUploadE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val videoFileName = "test_video.mp4" + + Log.d(PREPARATION_TAG, "Setup the '$videoFileName' file on the device.") + setupFileOnDevice(videoFileName) + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Navigate to the global 'Files' Page from the left side menu.") + leftSideNavigationDrawerPage.clickFilesMenu() + + Log.d(STEP_TAG, "Click on the 'Add' (+) icon and after that on the 'Upload File' icon.") + fileListPage.clickAddButton() + fileListPage.clickUploadFileButton() + + Log.d(PREPARATION_TAG, "Simulate file picker intent for '$videoFileName'.") + Intents.init() + try { + stubFilePickerIntent(videoFileName) + fileChooserPage.chooseDevice() + } finally { + Intents.release() + } + + Log.d(STEP_TAG, "Click on the 'Upload' button.") + fileChooserPage.clickUpload() + + Log.d(ASSERTION_TAG, "Assert that '$videoFileName' is displayed in My Files after the upload.") + retryWithIncreasingDelay(times = 10, maxDelay = 3000, catchBlock = { + triggerWorkManagerJobs("FileUploadWorker", 20000) + }) { + fileListPage.assertItemDisplayed(videoFileName) + } + + Log.d(STEP_TAG, "Click on '$videoFileName' to open it.") + fileListPage.selectItem(videoFileName) + + Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") + videoPlayerPage.assertMediaCommentPreviewDisplayed() + + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the video.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current video position.") + var firstVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "First video position: $firstVideoPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the video position again.") + var secondVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "Second video position: $secondVideoPositionText") + + Log.d(ASSERTION_TAG, "Assert that the video position has changed, confirming video is playing.") + assert(firstVideoPositionText != secondVideoPositionText) { + "Video position did not change. First: $firstVideoPositionText, Second: $secondVideoPositionText" + } + + Log.d(STEP_TAG, "Navigate back to Dashboard Page.") + pressBackButton(2) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open '${course.name}' course and navigate to the Files tab.") + dashboardPage.openCourse(course.name) + courseBrowserPage.openFilesTab() + + Log.d(STEP_TAG, "Click on the 'Add' (+) icon and after that on the 'Upload File' icon.") + fileListPage.clickAddButton() + fileListPage.clickUploadFileButton() + + Log.d(PREPARATION_TAG, "Simulate file picker intent for '$videoFileName'.") + Intents.init() + try { + stubFilePickerIntent(videoFileName) + fileChooserPage.chooseDevice() + } finally { + Intents.release() + } + + Log.d(STEP_TAG, "Click on the 'Upload' button.") + fileChooserPage.clickUpload() + + Log.d(ASSERTION_TAG, "Assert that '$videoFileName' is displayed in course files after the upload.") + retryWithIncreasingDelay(times = 10, maxDelay = 3000, catchBlock = { + triggerWorkManagerJobs("FileUploadWorker", 20000) + }) { + fileListPage.assertItemDisplayed(videoFileName) + } + + Log.d(STEP_TAG, "Click on '$videoFileName' to open it.") + fileListPage.selectItem(videoFileName) + + Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") + videoPlayerPage.assertMediaCommentPreviewDisplayed() + + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the video.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current video position.") + firstVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "First video position: $firstVideoPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the video position again.") + secondVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "Second video position: $secondVideoPositionText") + + Log.d(ASSERTION_TAG, "Assert that the video position has changed, confirming video is playing.") + assert(firstVideoPositionText != secondVideoPositionText) { + "Video position did not change. First: $firstVideoPositionText, Second: $secondVideoPositionText" + } + } + } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/LoginE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/LoginE2ETest.kt index aed1c65b2d..008090edc2 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/LoginE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/LoginE2ETest.kt @@ -16,8 +16,11 @@ */ package com.instructure.teacher.ui.e2e.classic +import android.app.Instrumentation import android.util.Log import androidx.test.espresso.Espresso +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.SecondaryFeatureCategory @@ -204,13 +207,15 @@ class LoginE2ETest : TeacherTest() { @E2E @Test + @Stub("MBL-19866") @TestMetaData(Priority.IMPORTANT, FeatureCategory.LOGIN, TestCategory.E2E) fun testInvalidAndEmptyLoginCredentialsE2E() { val INVALID_USERNAME = "invalidusercred@test.com" val INVALID_PASSWORD = "invalidpw" - val INVALID_CREDENTIALS_ERROR_MESSAGE = "Please verify your username or password and try again. Trouble logging in? Check out our Login FAQs." - val NO_PASSWORD_GIVEN_ERROR_MESSAGE = "No password was given" + val INVALID_CREDENTIALS_ERROR_MESSAGE = "Please verify your email or password and try again." + val NO_EMAIL_GIVEN_ERROR_MESSAGE = "Please enter your email." + val NO_PASSWORD_GIVEN_ERROR_MESSAGE = "Please enter your password." val DOMAIN = "mobileqa.beta" Log.d(STEP_TAG, "Click 'Find My School' button.") @@ -222,29 +227,32 @@ class LoginE2ETest : TeacherTest() { Log.d(STEP_TAG, "Click on 'Next' button on the Toolbar.") loginFindSchoolPage.clickToolbarNextMenuItem() - Log.d(STEP_TAG, "Try to login with invalid, non-existing credentials: '$INVALID_USERNAME', '$INVALID_PASSWORD'.") + /* Somehow React does not recognize the invalid credentials, need to be fixed in follow-up ticket + Log.d(STEP_TAG, "Try to login with invalid, non-existing credentials ('$INVALID_USERNAME', '$INVALID_PASSWORD').") loginSignInPage.loginAs(INVALID_USERNAME, INVALID_PASSWORD) Log.d(ASSERTION_TAG, "Assert that the invalid credentials error message is displayed.") - loginSignInPage.assertLoginErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) + loginSignInPage.assertLoginEmailErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) // Invalid credentials error message will be displayed within the email error message holder on the login page. + */ Log.d(STEP_TAG, "Try to login with no credentials typed in either of the username and password field.") loginSignInPage.loginAs(EMPTY_STRING, EMPTY_STRING) - Log.d(ASSERTION_TAG, "Assert that the no password was given error message is displayed.") - loginSignInPage.assertLoginErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) + Log.d(ASSERTION_TAG, "Assert that the no email and no password error messages are displayed.") + loginSignInPage.assertLoginEmailErrorMessage(NO_EMAIL_GIVEN_ERROR_MESSAGE) + loginSignInPage.assertLoginPasswordErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) Log.d(STEP_TAG, "Try to login with leaving only the password field empty.") loginSignInPage.loginAs(INVALID_USERNAME, EMPTY_STRING) Log.d(ASSERTION_TAG, "Assert that the no password was given error message is displayed.") - loginSignInPage.assertLoginErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) + loginSignInPage.assertLoginEmailErrorMessage(NO_PASSWORD_GIVEN_ERROR_MESSAGE) Log.d(STEP_TAG, "Try to login with leaving only the username field empty.") loginSignInPage.loginAs(EMPTY_STRING, INVALID_PASSWORD) - Log.d(ASSERTION_TAG, "Assert that the invalid credentials error message is displayed.") - loginSignInPage.assertLoginErrorMessage(INVALID_CREDENTIALS_ERROR_MESSAGE) + Log.d(ASSERTION_TAG, "Assert that the no email error message is displayed.") + loginSignInPage.assertLoginEmailErrorMessage(NO_EMAIL_GIVEN_ERROR_MESSAGE) // Invalid credentials error message will be displayed within the email error message holder on the login page. } @Test @@ -299,6 +307,36 @@ class LoginE2ETest : TeacherTest() { loginSignInPage.assertPageObjects() } + @E2E + @Test + @TestMetaData(Priority.NICE_TO_HAVE, FeatureCategory.LOGIN, TestCategory.E2E) + fun testLoginHowDoIFindMySchoolE2E() { + + Log.d(STEP_TAG, "Click 'Find My School' button.") + loginLandingPage.clickFindMySchoolButton() + + Log.d(STEP_TAG, "Enter and invalid domain to trigger the 'Tap here for login help.' link to be displayed.") + loginFindSchoolPage.enterDomain("invalid-domain") + + Log.d(ASSERTION_TAG, "Assert that the 'Tap here for login help.' link is displayed.") + loginFindSchoolPage.assertHowDoIFindMySchoolLinkDisplayed() + + val expectedUrl = "https://community.instructure.com/en/kb/articles/662717-where-do-i-find-my-institutions-url-to-access-canvas" + val expectedIntent = IntentMatchers.hasData(expectedUrl) + Intents.init() + try { + Intents.intending(expectedIntent).respondWith(Instrumentation.ActivityResult(0, null)) + + Log.d(STEP_TAG, "Click on the 'Tap here for login help.' link.") + loginFindSchoolPage.clickOnHowDoIFindMySchoolLink() + + Log.d(ASSERTION_TAG, "Assert that an intent with the correct URL was fired.") + Intents.intended(expectedIntent) + } finally { + Intents.release() + } + } + private fun loginWithUser(user: CanvasUserApiModel, lastSchoolSaved: Boolean = false) { if(lastSchoolSaved) { diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/PeopleE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/PeopleE2ETest.kt index 170841f22f..90b94db5ec 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/PeopleE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/PeopleE2ETest.kt @@ -30,6 +30,8 @@ import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 +import com.instructure.espresso.retryWithIncreasingDelay +import com.instructure.teacher.R import com.instructure.teacher.ui.pages.classic.PeopleListPage import com.instructure.teacher.ui.pages.classic.PersonContextPage import com.instructure.teacher.ui.utils.TeacherComposeTest @@ -246,4 +248,96 @@ class PeopleE2ETest: TeacherComposeTest() { inboxPage.assertHasConversation() } + @E2E + @Test + @TestMetaData(Priority.COMMON, FeatureCategory.PEOPLE, TestCategory.E2E) + fun testHiddenGradeDisplaysBothBeforeAndAfterGradesE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(teachers = 1, courses = 1, students = 1, favoriteCourses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student = data.studentsList[0] + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignments = seedAssignments( + courseId = course.id, + dueAt = 1.days.fromNow.iso8601, + submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), + teacherToken = teacher.token, + pointsPossible = 15.0 + ) + + Log.d(PREPARATION_TAG, "Seed a submission for '${assignments[0].name}' assignment with '${student.name}' student.") + seedAssignmentSubmission( + submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( + amount = 1, + submissionType = SubmissionType.ONLINE_TEXT_ENTRY + )), + assignmentId = assignments[0].id, + courseId = course.id, + studentToken = student.token + ) + + Log.d(PREPARATION_TAG, "Grade the previously seeded submission for '${student.name}' student.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignments[0].id, student.id, postedGrade = "15") + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open '${course.name}' course and navigate to People tab.") + dashboardPage.openCourse(course.name) + courseBrowserPage.openPeopleTab() + + Log.d(STEP_TAG, "Click on '${student.name}' student.") + peopleListPage.clickPerson(student) + + Log.d(ASSERTION_TAG, "Assert that only 1 grade box is shown while the grade is posted: 'Grade before posting' shows '100.0' (current grade) and 'Grade after posting' container is not visible since both values are equal.") + studentContextPage.assertStudentGrade("100.0") + studentContextPage.assertGradeAfterPostingNotDisplayed() + + Log.d(STEP_TAG, "Navigate back to the Course Browser Page.") + pressBackButton(2) + + Log.d(STEP_TAG, "Navigate to Assignments tab.") + courseBrowserPage.openAssignmentsTab() + + Log.d(STEP_TAG, "Click on '${assignments[0].name}' assignment.") + assignmentListPage.clickAssignment(assignments[0]) + + Log.d(STEP_TAG, "Open (all) submissions.") + assignmentDetailsPage.clickAllSubmissions() + + Log.d(STEP_TAG, "Click on 'Post Policies' (eye) icon.") + assignmentSubmissionListPage.clickOnPostPolicies() + + Log.d(STEP_TAG, "Click on 'Hide Grades' tab.") + postSettingsPage.clickOnTab(R.string.hideGradesTab) + + Log.d(ASSERTION_TAG, "Assert that there is 1 grade which is currently posted.") + postSettingsPage.assertPostPolicyStatusCount(1, false) + + Log.d(STEP_TAG, "Click on 'Hide Grades' button. It will automatically navigate back to the Assignment Submission List Page.") + postSettingsPage.clickOnHideGradesButton() + + Log.d(ASSERTION_TAG, "Assert that the hide grades (eye) icon is displayed next to '${student.name}'.") + retryWithIncreasingDelay(initialDelay = 500, maxDelay = 5000, times = 5) { + assignmentSubmissionListPage.assertGradesHidden(student.name) + } + + Log.d(STEP_TAG, "Navigate back to Course Browser Page.") + pressBackButton(3) + + Log.d(STEP_TAG, "Navigate to People tab.") + courseBrowserPage.openPeopleTab() + + Log.d(STEP_TAG, "Click on '${student.name}' student.") + peopleListPage.clickPerson(student) + + Log.d(ASSERTION_TAG, "Assert that both grade boxes are now shown: 'Grade before posting' shows '--' (what students currently see since the grade is hidden) and 'Grade after posting' shows '100.0' (what they will see once the grade is posted).") + studentContextPage.assertStudentGrade("--") + studentContextPage.assertStudentGradeAfterPosting("100.0") + } + } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/TodoE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/TodoE2ETest.kt index 6c918e2bc8..5cec37a8e2 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/TodoE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/classic/TodoE2ETest.kt @@ -65,11 +65,11 @@ class TodoE2ETest : TeacherTest() { tokenLogin(teacher) dashboardPage.waitForRender() - Log.d(STEP_TAG, "Navigate to 'To Do' Page.") + Log.d(STEP_TAG, "Navigate to 'To-do' Page.") dashboardPage.openTodo() todoPage.waitForRender() - Log.d(ASSERTION_TAG, "Assert that the empty view is displayed because there are no To Do items yet.") + Log.d(ASSERTION_TAG, "Assert that the empty view is displayed because there are no To-do items yet.") todoPage.assertEmptyView() Log.d(PREPARATION_TAG, "Seed a submission for '${testAssignment.name}' assignment with '${student.name}' student.") @@ -90,7 +90,7 @@ class TodoE2ETest : TeacherTest() { // region Make seeded quiz published manually - // We need to make the seeded quiz manually published because if we seed it published by default, seeding a submission for it will be automatically 'Graded' status so won't be displayed among the 'To Do' items. + // We need to make the seeded quiz manually published because if we seed it published by default, seeding a submission for it will be automatically 'Graded' status so won't be displayed among the 'To-do' items. Log.d(STEP_TAG, "Click on the '${testQuiz.title}' quiz.") quizListPage.clickQuiz(testQuiz.title) @@ -112,30 +112,30 @@ class TodoE2ETest : TeacherTest() { Log.d(STEP_TAG, "Navigate back to the Dashboard Page.") pressBackButton(3) - Log.d(STEP_TAG, "Navigate to 'To Do' Page.") + Log.d(STEP_TAG, "Navigate to 'To-do' Page.") dashboardPage.openTodo() todoPage.waitForRender() - Log.d(ASSERTION_TAG, "Assert that the previously seeded '${testAssignment.name}' assignment is displayed as a To Do element for the '${course.name}' course." + + Log.d(ASSERTION_TAG, "Assert that the previously seeded '${testAssignment.name}' assignment is displayed as a To-do element for the '${course.name}' course." + "Assert that the '1 Needs Grading' text is under the corresponding assignment's details.") todoPage.assertTodoElementDetailsDisplayed(course.name, testAssignment.name) todoPage.assertNeedsGradingCountOfTodoElement(assignments[0].name, 1) - Log.d(ASSERTION_TAG, "Assert that the previously seeded '${testQuiz.title}' quiz is displayed as a To Do element for the '${course.name}' course." + + Log.d(ASSERTION_TAG, "Assert that the previously seeded '${testQuiz.title}' quiz is displayed as a To-do element for the '${course.name}' course." + "Assert that the '1 Needs Grading' text is under the corresponding quiz's details.") todoPage.assertTodoElementDetailsDisplayed(course.name, testQuiz.title) todoPage.assertNeedsGradingCountOfTodoElement(testQuiz.title, 1) - Log.d(ASSERTION_TAG, "Assert that the 'To Do' element count is 2, since we have a quiz and an assignment which needs to be graded.") + Log.d(ASSERTION_TAG, "Assert that the 'To-do' element count is 2, since we have a quiz and an assignment which needs to be graded.") todoPage.assertTodoElementCount(2) Log.d(PREPARATION_TAG, "Grade the previously seeded '${testAssignment.name}' assignment for '${student.name}' student.") SubmissionsApi.gradeSubmission(teacher.token, course.id, testAssignment.id, student.id, postedGrade = "15") - Log.d(STEP_TAG, "Refresh the To Do Page.") + Log.d(STEP_TAG, "Refresh the To-do Page.") todoPage.refresh() - Log.d(ASSERTION_TAG, "Assert that the 'To Do' element count is 1, since we just graded the '${testAssignment.id}' assignment but we haven't graded the '${testQuiz.title}' quiz yet.") + Log.d(ASSERTION_TAG, "Assert that the 'To-do' element count is 1, since we just graded the '${testAssignment.id}' assignment but we haven't graded the '${testQuiz.title}' quiz yet.") todoPage.assertTodoElementCount(1) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/AssignmentE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/AssignmentE2ETest.kt index 6752bf9d98..3a2b100dab 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/AssignmentE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/AssignmentE2ETest.kt @@ -16,8 +16,11 @@ */ package com.instructure.teacher.ui.e2e.compose +import android.os.SystemClock.sleep import android.util.Log +import androidx.media3.ui.R import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority @@ -27,6 +30,7 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.AssignmentsApi +import com.instructure.dataseeding.api.FileUploadsApi import com.instructure.dataseeding.api.SectionsApi import com.instructure.dataseeding.api.SubmissionsApi import com.instructure.dataseeding.model.FileUploadType @@ -35,6 +39,7 @@ import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 +import com.instructure.espresso.getVideoPosition import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.espresso.triggerWorkManagerJobs import com.instructure.teacher.ui.utils.TeacherComposeTest @@ -434,12 +439,71 @@ class AssignmentE2ETest : TeacherComposeTest() { Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") speedGraderPage.assertMediaCommentPreviewDisplayed() + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the audio.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current audio position.") + val firstAudioPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "First audio position: $firstAudioPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume audio playback, wait for audio to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the audio position again.") + val secondAudioPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "Second audio position: $secondAudioPositionText") + + Log.d(ASSERTION_TAG, "Assert that the audio position has changed, confirming audio is playing.") + assert(firstAudioPositionText != secondAudioPositionText) { + "Audio position did not change. First: $firstAudioPositionText, Second: $secondAudioPositionText" + } + Log.d(STEP_TAG, "Navigate back. Click on the previously uploaded video comment.") Espresso.pressBack() speedGraderPage.clickOnMediaComment("Media Upload - Video") Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") speedGraderPage.assertMediaCommentPreviewDisplayed() + + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the video.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current video position.") + val firstVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "First video position: $firstVideoPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the video position again.") + val secondVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "Second video position: $secondVideoPositionText") + + Log.d(ASSERTION_TAG, "Assert that the video position has changed, confirming video is playing.") + assert(firstVideoPositionText != secondVideoPositionText) { + "Video position did not change. First: $firstVideoPositionText, Second: $secondVideoPositionText" + } + + Log.d(STEP_TAG, "Navigate back to SpeedGrader.") + Espresso.pressBack() } @E2E @@ -663,4 +727,96 @@ class AssignmentE2ETest : TeacherComposeTest() { assignmentSubmissionListPage.assertHasStudentSubmission(student2) } + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.E2E) + fun testVideoFileUploadSubmissionE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Seeding 'File Upload' assignment for '${course.name}' course.") + val videoUploadAssignment = AssignmentsApi.createAssignment( + AssignmentsApi.CreateAssignmentRequest( + courseId = course.id, + submissionTypes = listOf(SubmissionType.ONLINE_UPLOAD), + gradingType = GradingType.POINTS, + teacherToken = teacher.token, + pointsPossible = 15.0, + dueAt = 1.days.fromNow.iso8601 + ) + ) + + Log.d(PREPARATION_TAG, "Upload the mp4 file from assets as an assignment submission file.") + val videoFileName = "test_video.mp4" + val videoFileBytes = InstrumentationRegistry.getInstrumentation().context.assets.open(videoFileName).readBytes() + val uploadInfo = FileUploadsApi.uploadFile( + courseId = course.id, + assignmentId = videoUploadAssignment.id, + file = videoFileBytes, + fileName = videoFileName, + token = student.token, + fileUploadType = FileUploadType.ASSIGNMENT_SUBMISSION + ) + + Log.d(PREPARATION_TAG, "Submit '${videoUploadAssignment.name}' assignment for '${student.name}' student with the uploaded mp4 file.") + SubmissionsApi.submitCourseAssignment(course.id, student.token, videoUploadAssignment.id, SubmissionType.ONLINE_UPLOAD, fileIds = mutableListOf(uploadInfo.id)) + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select '${course.name}' course and navigate to its Assignments Page.") + dashboardPage.selectCourse(course) + courseBrowserPage.openAssignmentsTab() + + Log.d(STEP_TAG, "Click on '${videoUploadAssignment.name}' assignment.") + assignmentListPage.clickAssignment(videoUploadAssignment) + + Log.d(STEP_TAG, "Open '${student.name}' student's submission in SpeedGrader.") + assignmentDetailsPage.clickAllSubmissions() + assignmentSubmissionListPage.clickSubmission(student) + + Log.d(STEP_TAG, "Expand then collapse the SpeedGrader panel to show the video submission content in full.") + speedGraderPage.clickExpandPanelButton() + speedGraderPage.clickCollapsePanelButton() + + Log.d(ASSERTION_TAG, "Assert that '$videoFileName' attachment is displayed in SpeedGrader.") + speedGraderPage.assertSelectedAttachmentItemDisplayed(videoFileName) + + Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") + videoPlayerPage.assertMediaCommentPreviewDisplayed() + + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) + + Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") + videoPlayerPage.assertPlayPauseButtonDisplayed() + + Log.d(STEP_TAG, "Click play/pause button to pause the video.") + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the current video position.") + val firstVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "First video position: $firstVideoPositionText") + + Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") + videoPlayerPage.clickPlayPauseButton() + sleep(2000) + videoPlayerPage.clickPlayPauseButton() + + Log.d(STEP_TAG, "Get the video position again.") + val secondVideoPositionText = getVideoPosition(R.id.exo_position) + Log.d(ASSERTION_TAG, "Second video position: $secondVideoPositionText") + + Log.d(ASSERTION_TAG, "Assert that the video position has changed, confirming video is playing.") + assert(firstVideoPositionText != secondVideoPositionText) { + "Video position did not change. First: $firstVideoPositionText, Second: $secondVideoPositionText" + } + } + } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/CalendarE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/CalendarE2ETest.kt index cd26633dd7..9c6a40d819 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/CalendarE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/CalendarE2ETest.kt @@ -158,12 +158,12 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(ASSERTION_TAG, "Assert that the page title is 'Calendar'.") calendarScreenPage.assertCalendarPageTitle() - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo Title" val testTodoDescription = "Details of ToDo" @@ -173,26 +173,26 @@ class CalendarE2ETest: TeacherComposeTest() { calendarToDoCreateUpdatePage.clickSave() val currentDate = getDateInCanvasCalendarFormat() - Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously created To Do item is displayed with the corresponding title, context and date.") - calendarScreenPage.assertItemDetails(testTodoTitle, "To Do", "$currentDate at 12:00 PM") + Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously created To-do item is displayed with the corresponding title, context and date.") + calendarScreenPage.assertItemDetails(testTodoTitle, "To-do", "$currentDate at 12:00 PM") - Log.d(STEP_TAG, "Clicks on the '$testTodoTitle' To Do item.") + Log.d(STEP_TAG, "Clicks on the '$testTodoTitle' To-do item.") calendarScreenPage.clickOnItem(testTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle', the context is 'To Do', the date is the current day with 12:00 PM time and the description is '$testTodoDescription'.") + Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle', the context is 'To-do', the date is the current day with 12:00 PM time and the description is '$testTodoDescription'.") calendarToDoDetailsPage.assertTitle(testTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") calendarToDoDetailsPage.assertDate("$currentDate at 12:00 PM") calendarToDoDetailsPage.assertDescription(testTodoDescription) - Log.d(STEP_TAG, "Click on the 'Edit To Do' within the toolbar more menu and confirm the editing.") + Log.d(STEP_TAG, "Click on the 'Edit To-do' within the toolbar more menu and confirm the editing.") calendarToDoDetailsPage.clickToolbarMenu() calendarToDoDetailsPage.clickEditMenu() - Log.d(ASSERTION_TAG, "Assert that the page title is 'Edit To Do' as we are editing an existing To Do item.") - calendarToDoCreateUpdatePage.assertPageTitle("Edit To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'Edit To-do' as we are editing an existing To-do item.") + calendarToDoCreateUpdatePage.assertPageTitle("Edit To-do") - Log.d(ASSERTION_TAG, "Assert that the 'original' To Do Title and details has been filled into the input fields as we on the edit screen.") + Log.d(ASSERTION_TAG, "Assert that the 'original' To-do Title and details has been filled into the input fields as we on the edit screen.") calendarToDoCreateUpdatePage.assertTodoTitle(testTodoTitle) calendarToDoCreateUpdatePage.assertDetails(testTodoDescription) @@ -203,19 +203,19 @@ class CalendarE2ETest: TeacherComposeTest() { calendarToDoCreateUpdatePage.typeDetails(modifiedTestTodoDescription) calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously modified To Do item is displayed with the corresponding title, context and with the same date as we haven't changed it.") - calendarScreenPage.assertItemDetails(modifiedTestTodoTitle, "To Do", "$currentDate at 12:00 PM") + Log.d(ASSERTION_TAG, "Assert that the user has been navigated back to the Calendar Screen Page and that the previously modified To-do item is displayed with the corresponding title, context and with the same date as we haven't changed it.") + calendarScreenPage.assertItemDetails(modifiedTestTodoTitle, "To-do", "$currentDate at 12:00 PM") - Log.d(STEP_TAG, "Clicks on the '$modifiedTestTodoTitle' To Do item.") + Log.d(STEP_TAG, "Clicks on the '$modifiedTestTodoTitle' To-do item.") calendarScreenPage.clickOnItem(modifiedTestTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the To Do title is '$modifiedTestTodoTitle', the page title is 'To Do', the date remained current day with 12:00 PM time (as we haven't modified it) and the description is '$modifiedTestTodoDescription'.") + Log.d(ASSERTION_TAG, "Assert that the To-do title is '$modifiedTestTodoTitle', the page title is 'To-do', the date remained current day with 12:00 PM time (as we haven't modified it) and the description is '$modifiedTestTodoDescription'.") calendarToDoDetailsPage.assertTitle(modifiedTestTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") calendarToDoDetailsPage.assertDate("$currentDate at 12:00 PM") calendarToDoDetailsPage.assertDescription(modifiedTestTodoDescription) - Log.d(STEP_TAG, "Click on the 'Delete To Do' within the toolbar more menu and confirm the deletion.") + Log.d(STEP_TAG, "Click on the 'Delete To-do' within the toolbar more menu and confirm the deletion.") calendarToDoDetailsPage.clickToolbarMenu() calendarToDoDetailsPage.clickDeleteMenu() calendarToDoDetailsPage.confirmDeletion() @@ -223,7 +223,7 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(ASSERTION_TAG, "Assert that the deleted item does not exist anymore on the Calendar Screen Page.") calendarScreenPage.assertItemNotExist(modifiedTestTodoTitle) - Log.d(ASSERTION_TAG, "Assert that after the deletion the empty view will be displayed since we don't have any To Do items on the current day.") + Log.d(ASSERTION_TAG, "Assert that after the deletion the empty view will be displayed since we don't have any To-do items on the current day.") calendarScreenPage.assertEmptyView() } @@ -273,12 +273,12 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(ASSERTION_TAG, "Assert that the event is displayed with the corresponding details (title, context name, date, status) on the page.") calendarScreenPage.assertItemDetails(newEventTitle, teacher.name, currentDate) - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo Title" val testTodoDescription = "Details of ToDo" @@ -294,7 +294,7 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(STEP_TAG, "Click on the 'Save' button.") calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To Do item is displayed because we created it to this particular day." + + Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To-do item is displayed because we created it to this particular day." + "Assert that '$newEventTitle' calendar event is not displayed because it's created for today.") calendarScreenPage.assertItemDisplayed(testTodoTitle) calendarScreenPage.assertItemNotExist(newEventTitle) @@ -306,7 +306,7 @@ class CalendarE2ETest: TeacherComposeTest() { calendarFilterPage.clickOnFilterItem(course.name) calendarFilterPage.closeFilterPage() - Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To Do item is not displayed because we filtered out from the calendar. " + + Log.d(ASSERTION_TAG, "Assert that the '$testTodoTitle' To-do item is not displayed because we filtered out from the calendar. " + "Assert that the empty view is displayed because there are no items for today.") calendarScreenPage.assertItemNotExist(testTodoTitle) calendarScreenPage.assertEmptyView() @@ -593,12 +593,12 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(ASSERTION_TAG, "Assert that the page title is 'Calendar'.") calendarScreenPage.assertCalendarPageTitle() - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo With Reminder" val testTodoDescription = "Details of ToDo" @@ -613,15 +613,24 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(STEP_TAG, "Click on the 'Save' button.") calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") - calendarScreenPage.assertItemDisplayed(testTodoTitle) - - Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To Do item.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar. If the app did not navigate to the correct day, navigate there manually by clicking on the day.") + try { + calendarScreenPage.assertItemDisplayed(testTodoTitle) + } catch (e: Throwable) { + if (calendarScreenPage.checkCalendarCollapsed()) { + Log.d(STEP_TAG, "Expand the calendar to make all day numbers visible.") + calendarScreenPage.clickCalendarHeader() + } + calendarScreenPage.clickOnDayNumber(futureDate.get(Calendar.DAY_OF_MONTH)) + calendarScreenPage.assertItemDisplayed(testTodoTitle) + } + + Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To-do item.") calendarScreenPage.clickOnItem(testTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To Do'.") + Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To-do'.") calendarToDoDetailsPage.assertTitle(testTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") Log.d(ASSERTION_TAG, "Assert that the reminder section is displayed.") calendarToDoDetailsPage.assertReminderSectionDisplayed() @@ -635,7 +644,7 @@ class CalendarE2ETest: TeacherComposeTest() { calendarToDoDetailsPage.selectDate(reminderDateOneHour) calendarToDoDetailsPage.selectTime(reminderDateOneHour) - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneHour.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -665,7 +674,7 @@ class CalendarE2ETest: TeacherComposeTest() { calendarToDoDetailsPage.selectDate(reminderDateOneWeek) calendarToDoDetailsPage.selectTime(reminderDateOneWeek) - Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To Do which is due in 2 days).") + Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To-do which is due in 2 days).") calendarToDoDetailsPage.assertReminderNotDisplayedWithText(reminderDateOneWeek.time.toFormattedString()) checkToastText(R.string.reminderInPast, activityRule.activity) futureDate.apply { add(Calendar.WEEK_OF_YEAR, 1) } @@ -679,7 +688,7 @@ class CalendarE2ETest: TeacherComposeTest() { calendarToDoDetailsPage.selectDate(reminderDateOneDay) calendarToDoDetailsPage.selectTime(reminderDateOneDay) - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneDay.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -697,7 +706,7 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(STEP_TAG, "Navigate back to Calendar Screen Page.") Espresso.pressBack() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) } @@ -724,12 +733,12 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(ASSERTION_TAG, "Assert that the page title is 'Calendar'.") calendarScreenPage.assertCalendarPageTitle() - Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To Do' to create a new To Do.") + Log.d(STEP_TAG, "Click on the 'Add' (FAB) button and 'Add To-do' to create a new To-do.") calendarScreenPage.clickOnAddButton() calendarScreenPage.clickAddTodo() - Log.d(ASSERTION_TAG, "Assert that the page title is 'New To Do' as we are clicked on the 'Add To Do' button to create a new one.") - calendarToDoCreateUpdatePage.assertPageTitle("New To Do") + Log.d(ASSERTION_TAG, "Assert that the page title is 'New To-do' as we are clicked on the 'Add To-do' button to create a new one.") + calendarToDoCreateUpdatePage.assertPageTitle("New To-do") val testTodoTitle = "Test ToDo With Reminder" val testTodoDescription = "Details of ToDo" @@ -744,15 +753,24 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(STEP_TAG, "Click on the 'Save' button.") calendarToDoCreateUpdatePage.clickSave() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") - calendarScreenPage.assertItemDisplayed(testTodoTitle) - - Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To Do item.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar. If the app did not navigate to the correct day, navigate there manually by clicking on the day.") + try { + calendarScreenPage.assertItemDisplayed(testTodoTitle) + } catch (e: Throwable) { + if (calendarScreenPage.checkCalendarCollapsed()) { + Log.d(STEP_TAG, "Expand the calendar to make all day numbers visible.") + calendarScreenPage.clickCalendarHeader() + } + calendarScreenPage.clickOnDayNumber(futureDate.get(Calendar.DAY_OF_MONTH)) + calendarScreenPage.assertItemDisplayed(testTodoTitle) + } + + Log.d(STEP_TAG, "Click on the previously created '$testTodoTitle' To-do item.") calendarScreenPage.clickOnItem(testTodoTitle) - Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To Do'.") + Log.d(ASSERTION_TAG, "Assert that the title is '$testTodoTitle' and the context is 'To-do'.") calendarToDoDetailsPage.assertTitle(testTodoTitle) - calendarToDoDetailsPage.assertPageTitle("To Do") + calendarToDoDetailsPage.assertPageTitle("To-do") Log.d(ASSERTION_TAG, "Assert that the reminder section is displayed.") calendarToDoDetailsPage.assertReminderSectionDisplayed() @@ -764,7 +782,7 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(STEP_TAG, "Select '1 Hour Before'.") calendarToDoDetailsPage.clickBeforeReminderOption("1 Hour Before") - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneHour.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -790,7 +808,7 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(STEP_TAG, "Select '1 Week Before'.") calendarToDoDetailsPage.clickBeforeReminderOption("1 Week Before") - Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To Do which is due in 2 days).") + Log.d(ASSERTION_TAG, "Assert that a toast message is occurring which warns that we cannot pick up a reminder which has already passed (for example cannot pick '1 Week Before' reminder for a To-do which is due in 2 days).") calendarToDoDetailsPage.assertReminderNotDisplayedWithText(reminderDateOneWeek.time.toFormattedString()) checkToastText(R.string.reminderInPast, activityRule.activity) futureDate.apply { add(Calendar.WEEK_OF_YEAR, 1) } @@ -802,7 +820,7 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(STEP_TAG, "Select '1 Day Before'.") calendarToDoDetailsPage.clickBeforeReminderOption("1 Day Before") - Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To Do Details Page.") + Log.d(ASSERTION_TAG, "Assert that the reminder has been picked up and displayed on the To-do Details Page.") calendarToDoDetailsPage.assertReminderDisplayedWithText(reminderDateOneDay.time.toFormattedString()) Log.d(STEP_TAG, "Click on the '+' button (Add reminder) to pick up a new reminder.") @@ -818,7 +836,7 @@ class CalendarE2ETest: TeacherComposeTest() { Log.d(STEP_TAG, "Navigate back to Calendar Screen Page.") Espresso.pressBack() - Log.d(ASSERTION_TAG, "Assert that the To Do item is displayed on the calendar.") + Log.d(ASSERTION_TAG, "Assert that the To-do item is displayed on the calendar.") calendarScreenPage.assertItemDisplayed(testTodoTitle) } } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/InboxE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/InboxE2ETest.kt index 5a47464e9f..7bc32ffde6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/InboxE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/InboxE2ETest.kt @@ -14,7 +14,6 @@ * limitations under the License. * */ - package com.instructure.teacher.ui.e2e.compose import android.os.Environment @@ -32,15 +31,21 @@ import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E +import com.instructure.canvas.espresso.pressBackButton import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.ConversationsApi import com.instructure.dataseeding.api.GroupsApi import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.util.days +import com.instructure.dataseeding.util.fromNow +import com.instructure.dataseeding.util.iso8601 import com.instructure.espresso.getVideoPosition import com.instructure.espresso.retry import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.teacher.ui.utils.TeacherComposeTest +import com.instructure.teacher.ui.utils.extensions.seedAssignments import com.instructure.teacher.ui.utils.extensions.seedData import com.instructure.teacher.ui.utils.extensions.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest @@ -816,26 +821,26 @@ class InboxE2ETest : TeacherComposeTest() { Log.d(STEP_TAG, "Click on the attachment to verify it can be opened.") inboxDetailsPage.clickAttachment(videoFileName) - Log.d(ASSERTION_TAG, "Wait for video to load and assert that the media play button is visible.") - inboxDetailsPage.assertPlayButtonDisplayed() + Log.d(ASSERTION_TAG, "Assert that the media comment preview (and the 'Play button') is displayed.") + videoPlayerPage.assertMediaCommentPreviewDisplayed() - Log.d(STEP_TAG, "Click the play button to start the video and on the screen to show media controls.") - inboxDetailsPage.clickPlayButton() - inboxDetailsPage.clickScreenCenterToShowControls(device) + Log.d(STEP_TAG, "Click the play button to start the video and wait for it to finish loading.") + videoPlayerPage.clickPlayButton() + videoPlayerPage.waitForVideoToStart(device) Log.d(ASSERTION_TAG, "Assert that the play/pause button is visible in the media controls.") - inboxDetailsPage.assertPlayPauseButtonDisplayed() + videoPlayerPage.assertPlayPauseButtonDisplayed() Log.d(STEP_TAG, "Click play/pause button to pause the video.") - inboxDetailsPage.clickPlayPauseButton() + videoPlayerPage.clickPlayPauseButton() Log.d(STEP_TAG, "Get the current video position.") val firstPositionText = getVideoPosition(R.id.exo_position) Log.d(STEP_TAG, "Click play/pause button to resume video playback, wait for video to play for 2 seconds then click play/pause button to pause again.") - inboxDetailsPage.clickPlayPauseButton() + videoPlayerPage.clickPlayPauseButton() sleep(2000) - inboxDetailsPage.clickPlayPauseButton() + videoPlayerPage.clickPlayPauseButton() Log.d(STEP_TAG, "Get the video position again.") val secondPositionText = getVideoPosition(R.id.exo_position) @@ -1110,4 +1115,74 @@ class InboxE2ETest : TeacherComposeTest() { inboxDetailsPage.assertMessageDisplayed(conversationBody) } + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.INBOX, TestCategory.E2E) + fun testSendMessageFromAllSubmissionsPageE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 2, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student1 = data.studentsList[0] + val student2 = data.studentsList[1] + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignment = seedAssignments( + courseId = course.id, + dueAt = 1.days.fromNow.iso8601, + submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), + teacherToken = teacher.token, + pointsPossible = 10.0 + ) + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open '${course.name}' course and navigate to the Assignments page.") + dashboardPage.openCourse(course) + courseBrowserPage.openAssignmentsTab() + + Log.d(STEP_TAG, "Navigate to All Submissions page for '${assignment[0].name}' assignment.") + assignmentListPage.clickAssignment(assignment[0]) + assignmentDetailsPage.clickAllSubmissions() + + Log.d(ASSERTION_TAG, "Assert that both '${student1.name}' and '${student2.name}' are listed on the All Submissions page.") + assignmentSubmissionListPage.assertHasSubmission(2) + assignmentSubmissionListPage.assertHasStudentSubmission(student1) + assignmentSubmissionListPage.assertHasStudentSubmission(student2) + + Log.d(STEP_TAG, "Click the 'Add Message' button on the toolbar.") + assignmentSubmissionListPage.clickAddMessage() + + Log.d(ASSERTION_TAG, "Assert that both students are pre-populated as recipients.") + inboxComposePage.assertRecipientSelected(student1.shortName) + inboxComposePage.assertRecipientSelected(student2.shortName) + + val body = "This message was sent from the All Submissions page." + Log.d(STEP_TAG, "Type body '$body' and send the message.") + inboxComposePage.typeBody(body) + inboxComposePage.pressSendButton() + + Log.d(STEP_TAG, "Navigate back from All Submissions to the Dashboard.") + pressBackButton(4) + + Log.d(STEP_TAG, "Open Inbox.") + dashboardPage.openInbox() + + Log.d(STEP_TAG, "Filter the Inbox by selecting 'Sent' category.") + inboxPage.filterInbox("Sent") + + val subject = "All Submissions on ${assignment[0].name}" + Log.d(ASSERTION_TAG, "Assert that the sent conversation with subject '$subject' is displayed.") + inboxPage.assertConversationDisplayed(subject) + + Log.d(STEP_TAG, "Open the '$subject' conversation.") + inboxPage.openConversation(subject) + + Log.d(ASSERTION_TAG, "Assert that the message body '$body' is displayed in the conversation.") + inboxDetailsPage.assertMessageDisplayed(body) + } + } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/SpeedGraderE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/SpeedGraderE2ETest.kt index ee3e1e8d11..51c4577218 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/SpeedGraderE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/SpeedGraderE2ETest.kt @@ -17,7 +17,6 @@ package com.instructure.teacher.ui.e2e.compose import android.util.Log -import androidx.compose.ui.test.ExperimentalTestApi import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority @@ -26,10 +25,14 @@ import com.instructure.canvas.espresso.TestMetaData import com.instructure.canvas.espresso.annotations.E2E import com.instructure.canvas.espresso.pressBackButton import com.instructure.canvas.espresso.refresh +import com.instructure.dataseeding.api.LatePolicyApi import com.instructure.dataseeding.api.SubmissionsApi +import com.instructure.dataseeding.model.LatePolicy import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.util.ago import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow +import com.instructure.dataseeding.util.hours import com.instructure.dataseeding.util.iso8601 import com.instructure.espresso.page.getStringFromResource import com.instructure.espresso.retry @@ -51,7 +54,6 @@ class SpeedGraderE2ETest : TeacherComposeTest() { override fun enableAndConfigureAccessibilityChecks() = Unit - @OptIn(ExperimentalTestApi::class) @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.GRADES, TestCategory.E2E) @@ -101,6 +103,7 @@ class SpeedGraderE2ETest : TeacherComposeTest() { Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") tokenLogin(teacher) + dashboardPage.waitForRender() Log.d(STEP_TAG, "Open '${course.name}' course and navigate to Assignments Page.") dashboardPage.openCourse(course) @@ -130,10 +133,8 @@ class SpeedGraderE2ETest : TeacherComposeTest() { Log.d(ASSERTION_TAG, "Assert that the '${noSubStudent.name}' student has '-' as score as it's submission is not submitted yet.") assignmentSubmissionListPage.assertStudentScoreText(noSubStudent.name, "-") - Log.d(STEP_TAG, "Navigate back to the Assignment Details Page.") + Log.d(STEP_TAG, "Navigate back to the Assignment Details Page and click on 'View All Submission' arrow icon.") Espresso.pressBack() - - Log.d(STEP_TAG, "Click on 'View All Submission' arrow icon.") assignmentDetailsPage.clickAllSubmissions() Log.d(STEP_TAG, "Click on '${student.name}' student's avatar.") @@ -254,7 +255,254 @@ class SpeedGraderE2ETest : TeacherComposeTest() { assignmentSubmissionListPage.assertGradesHidden(gradedStudent.name) assignmentSubmissionListPage.assertGradesHidden(student.name) } + } + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.E2E) + fun testSpeedGraderQuickAccessButtonAndSwipeE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(teachers = 1, courses = 1, students = 3, favoriteCourses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val sortedStudents = data.studentsList.sortedBy { it.sortableName.lowercase() } + val firstSubmittedStudent = sortedStudents[0] + val secondGradedStudent = sortedStudents[1] + val thirdNoSubmissionStudent = sortedStudents[2] + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val assignment = seedAssignments( + courseId = course.id, + dueAt = 1.days.fromNow.iso8601, + submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), + teacherToken = teacher.token, + pointsPossible = 15.0 + ) + + Log.d(PREPARATION_TAG, "Seed a submission for '${assignment[0].name}' assignment with '${firstSubmittedStudent.name}' student.") + seedAssignmentSubmission( + submissionSeeds = listOf( + SubmissionsApi.SubmissionSeedInfo( + amount = 1, + submissionType = SubmissionType.ONLINE_TEXT_ENTRY + ) + ), + assignmentId = assignment[0].id, + courseId = course.id, + studentToken = firstSubmittedStudent.token + ) + + Log.d(PREPARATION_TAG, "Seed a submission for '${assignment[0].name}' assignment with '${secondGradedStudent.name}' student.") + seedAssignmentSubmission( + submissionSeeds = listOf( + SubmissionsApi.SubmissionSeedInfo( + amount = 1, + submissionType = SubmissionType.ONLINE_TEXT_ENTRY + ) + ), + assignmentId = assignment[0].id, + courseId = course.id, + studentToken = secondGradedStudent.token + ) + + Log.d(PREPARATION_TAG, "Grade the previously seeded submission for '${secondGradedStudent.name}' student.") + SubmissionsApi.gradeSubmission( + teacher.token, + course.id, + assignment[0].id, + secondGradedStudent.id, + postedGrade = "15" + ) + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open '${course.name}' course and navigate to Assignments Page.") + dashboardPage.openCourse(course) + courseBrowserPage.openAssignmentsTab() + + Log.d(STEP_TAG, "Click on '${assignment[0].name}' assignment.") + assignmentListPage.clickAssignment(assignment[0]) + + Log.d(STEP_TAG, "Open the SpeedGrader by clicking on the SpeedGrader quick access button.") + assignmentDetailsPage.openSpeedGrader() + + Log.d(ASSERTION_TAG, "Assert that the the initial (first) student is '${firstSubmittedStudent.name}'.") + speedGraderPage.assertCurrentStudent(firstSubmittedStudent.name) + speedGraderPage.assertCurrentStudentStatus("Submitted") + + Log.d(STEP_TAG, "Swipe to the next (second) student.") + speedGraderPage.swipeToNextStudent() + + Log.d(ASSERTION_TAG, "Assert that the current student is '${secondGradedStudent.name}'.") + speedGraderPage.assertCurrentStudent(secondGradedStudent.name) + speedGraderPage.assertCurrentStudentStatus("Graded") + + Log.d(STEP_TAG, "Swipe to the next (third) student.") + speedGraderPage.swipeToNextStudent() + + Log.d(ASSERTION_TAG, "Assert that the current student is '${thirdNoSubmissionStudent.name}'.") + speedGraderPage.assertCurrentStudent(thirdNoSubmissionStudent.name) + speedGraderPage.assertCurrentStudentStatus("Not Submitted") + } + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.E2E) + fun testSpeedGraderLatePenaltyE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(teachers = 1, courses = 1, students = 1, favoriteCourses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student = data.studentsList[0] + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course with a due date 12 hours in the past.") + val assignment = seedAssignments( + courseId = course.id, + dueAt = 12.hours.ago.iso8601, + submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), + teacherToken = teacher.token, + pointsPossible = 100.0 + ) + + Log.d(PREPARATION_TAG, "Creating a late policy for '${course.name}' course: 10% deduction per day.") + LatePolicyApi.createLatePolicy( + courseId = course.id, + latePolicy = LatePolicy( + missingSubmissionDeductionEnabled = false, + missingSubmissionDeduction = 0, + lateSubmissionDeductionEnabled = true, + lateSubmissionDeduction = 10, + lateSubmissionInterval = "day", + lateSubmissionMinimumPercentEnabled = false, + lateSubmissionMinimumPercent = 0 + ), + teacherToken = teacher.token + ) + + Log.d(PREPARATION_TAG, "Seeding a late submission for '${assignment[0].name}' assignment with '${student.name}' student.") + seedAssignmentSubmission( + submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( + amount = 1, + submissionType = SubmissionType.ONLINE_TEXT_ENTRY + )), + assignmentId = assignment[0].id, + courseId = course.id, + studentToken = student.token + ) + + Log.d(PREPARATION_TAG, "Grading the late submission for '${student.name}' student with 100 points.") + SubmissionsApi.gradeSubmission(teacher.token, course.id, assignment[0].id, student.id, postedGrade = "100") + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open '${course.name}' course and navigate to Assignments Page.") + dashboardPage.openCourse(course) + courseBrowserPage.openAssignmentsTab() + + Log.d(STEP_TAG, "Click on '${assignment[0].name}' assignment.") + assignmentListPage.clickAssignment(assignment[0]) + + Log.d(STEP_TAG, "Open (all) submissions and click on '${student.name}' student's submission.") + assignmentDetailsPage.clickAllSubmissions() + assignmentSubmissionListPage.clickSubmission(student) + + Log.d(ASSERTION_TAG, "Assert that the submission of '${student.name}' student is displayed in SpeedGrader.") + speedGraderPage.assertDisplaysTextSubmissionViewWithStudentName(student.name) + + Log.d(STEP_TAG, "Click on the 'Expand Panel Button' to show the grade panel.") + speedGraderPage.clickExpandPanelButton() + + Log.d(ASSERTION_TAG, "Assert that the entered score is '100'.") + speedGraderGradePage.assertCurrentEnteredScore("100") + + Log.d(ASSERTION_TAG, "Assert that the submission is 0.5 days late (12 hours).") + speedGraderGradePage.assertDaysLate("0.5") + + Log.d(ASSERTION_TAG, "Assert that the late penalty value is '10' (10% per day × 1 interval × 100 pts).") + speedGraderGradePage.assertLatePenaltyValueDisplayed("10") + + Log.d(ASSERTION_TAG, "Assert that the Points row shows '100 / 100 pts' (the raw entered grade before penalty).") + speedGraderGradePage.assertFinalGradePointsValueDisplayed("100 / 100 pts") + + Log.d(ASSERTION_TAG, "Assert that the final grade is displayed as '90 / 100 pts'.") + speedGraderGradePage.assertFinalGradeIsDisplayed("90 / 100 pts") + } + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.E2E) + fun testSpeedGraderOvergradingE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(teachers = 1, courses = 1, students = 1, favoriteCourses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student = data.studentsList[0] + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course with 10 max points.") + val assignment = seedAssignments( + courseId = course.id, + dueAt = 1.days.fromNow.iso8601, + submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY), + teacherToken = teacher.token, + pointsPossible = 10.0 + ) + + Log.d(PREPARATION_TAG, "Seed a submission for '${assignment[0].name}' assignment with '${student.name}' student.") + seedAssignmentSubmission( + submissionSeeds = listOf(SubmissionsApi.SubmissionSeedInfo( + amount = 1, + submissionType = SubmissionType.ONLINE_TEXT_ENTRY + )), + assignmentId = assignment[0].id, + courseId = course.id, + studentToken = student.token + ) + + Log.d(STEP_TAG, "Login with user: '${teacher.name}', login id: '${teacher.loginId}'.") + tokenLogin(teacher) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open '${course.name}' course and navigate to Assignments Page.") + dashboardPage.openCourse(course) + courseBrowserPage.openAssignmentsTab() + + Log.d(STEP_TAG, "Click on '${assignment[0].name}' assignment.") + assignmentListPage.clickAssignment(assignment[0]) + + Log.d(STEP_TAG, "Open (all) submissions and click on '${student.name}' student's submission.") + assignmentDetailsPage.clickAllSubmissions() + assignmentSubmissionListPage.clickSubmission(student) + + Log.d(ASSERTION_TAG, "Assert that the submission of '${student.name}' student is displayed in SpeedGrader.") + speedGraderPage.assertDisplaysTextSubmissionViewWithStudentName(student.name) + + Log.d(STEP_TAG, "Click on the 'Expand Panel Button' to show the grade panel.") + speedGraderPage.clickExpandPanelButton() + + Log.d(ASSERTION_TAG, "Assert that the assignment's max points is '10'.") + speedGraderGradePage.assertPointsPossible("10") + + Log.d(ASSERTION_TAG, "Assert that the status is 'None' (submission is on-time, no late penalty applies).") + speedGraderGradePage.assertSelectedStatusText("None") + + Log.d(STEP_TAG, "Enter '15' as the new grade (above the max of 10 points).") + speedGraderGradePage.enterNewGrade("15") + + Log.d(ASSERTION_TAG, "Assert that the entered score is '15'.") + speedGraderGradePage.assertCurrentEnteredScore("15") + + Log.d(ASSERTION_TAG, "Assert that the final grade points value is '15 / 10 pts' (overgraded above the 10 pts maximum).") + speedGraderGradePage.assertFinalGradePointsValueDisplayed("15 / 10 pts") + Log.d(ASSERTION_TAG, "Assert that the final grade is displayed as '15 / 10 pts'.") + speedGraderGradePage.assertFinalGradeIsDisplayed("15 / 10 pts") } } \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/interaction/AssignmentDueDatesInteractionTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/interaction/AssignmentDueDatesInteractionTest.kt index aa844bc695..a2375e49dc 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/interaction/AssignmentDueDatesInteractionTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/interaction/AssignmentDueDatesInteractionTest.kt @@ -80,7 +80,7 @@ class AssignmentDueDatesInteractionTest : TeacherComposeTest() { dashboardPage.openCourse(course) courseBrowserPage.openAssignmentsTab() assignmentListPage.clickAssignment(assignment) - assignmentDetailsPage.openAllDatesPage() + assignmentDetailsPage.openDueDatesPage() return assignment } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/interaction/CommentLibraryInteractionTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/interaction/CommentLibraryInteractionTest.kt index 2b26f40a90..f3ed287b2e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/interaction/CommentLibraryInteractionTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/interaction/CommentLibraryInteractionTest.kt @@ -20,6 +20,7 @@ import com.instructure.canvas.espresso.FeatureCategory import com.instructure.canvas.espresso.Priority import com.instructure.canvas.espresso.TestCategory import com.instructure.canvas.espresso.TestMetaData +import com.instructure.canvas.espresso.annotations.StubLandscape import com.instructure.canvas.espresso.mockcanvas.MockCanvas import com.instructure.canvas.espresso.mockcanvas.addAssignment import com.instructure.canvas.espresso.mockcanvas.addCoursePermissions @@ -138,6 +139,7 @@ class CommentLibraryInteractionTest : TeacherComposeTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.SPEED_GRADER, TestCategory.INTERACTION) + @StubLandscape fun selectCommentLibrarySuggestionComment() { createCommentLibraryMockData() goToSpeedGraderCommentsPage() @@ -155,6 +157,7 @@ class CommentLibraryInteractionTest : TeacherComposeTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.SPEED_GRADER, TestCategory.INTERACTION) + @StubLandscape fun selectCommentLibrarySuggestionAndSendComment() { createCommentLibraryMockData() goToSpeedGraderCommentsPage() @@ -194,6 +197,7 @@ class CommentLibraryInteractionTest : TeacherComposeTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.SPEED_GRADER, TestCategory.INTERACTION) + @StubLandscape fun reopenCommentLibraryWhenTextIsModified() { createCommentLibraryMockData() goToSpeedGraderCommentsPage() @@ -227,6 +231,7 @@ class CommentLibraryInteractionTest : TeacherComposeTest() { @Test @TestMetaData(Priority.COMMON, FeatureCategory.SPEED_GRADER, TestCategory.INTERACTION) + @StubLandscape fun selectCommentLibrarySuggestionFromMultipleItemResult() { createCommentLibraryMockData() goToSpeedGraderCommentsPage() @@ -247,6 +252,7 @@ class CommentLibraryInteractionTest : TeacherComposeTest() { @Test @TestMetaData(Priority.COMMON, FeatureCategory.SPEED_GRADER, TestCategory.INTERACTION) + @StubLandscape fun showAllCommentLibraryItemsAfterClearingCommentFieldFilter() { val commentLibraryItems = createCommentLibraryMockData() goToSpeedGraderCommentsPage() diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/AssignmentDetailsPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/AssignmentDetailsPage.kt index fd9a5cdabc..be6bf72add 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/AssignmentDetailsPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/AssignmentDetailsPage.kt @@ -70,6 +70,7 @@ class AssignmentDetailsPage(val moduleItemInteractions: ModuleItemInteractions) private val submissionTypesTextView by OnViewWithId(R.id.submissionTypesTextView) private val instructionsSectionLabel by OnViewWithId(R.id.instructionsSectionLabel, autoAssert = false) private val editButton by OnViewWithId(R.id.menu_edit) + private val speedGraderQuickAccessButton by OnViewWithId(R.id.menu_speedGrader) private val dueDatesLayout by OnViewWithId(R.id.dueLayout) private val submissionsLayout by OnViewWithId(R.id.submissionsLayout) private val viewAllSubmissions by OnViewWithId(R.id.viewAllSubmissions) @@ -101,10 +102,10 @@ class AssignmentDetailsPage(val moduleItemInteractions: ModuleItemInteractions) } /** - * Open all dates page (by clicking on the due dates layout). + * Open Due Dates page (by clicking on the due dates layout). * */ - fun openAllDatesPage() { + fun openDueDatesPage() { dueDatesLayout.click() } @@ -116,6 +117,14 @@ class AssignmentDetailsPage(val moduleItemInteractions: ModuleItemInteractions) editButton.click() } + /** + * Open the SpeedGrader page (by clicking on the SpeedGrader quick access button). + * + */ + fun openSpeedGrader() { + speedGraderQuickAccessButton.click() + } + /** * Open 'All Submissions Page' (by clicking on the 'All' button). * diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/CourseBrowserPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/CourseBrowserPage.kt index a8311a954a..f9e3b19bdd 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/CourseBrowserPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/CourseBrowserPage.kt @@ -131,6 +131,13 @@ class CourseBrowserPage : BasePage() { waitForViewWithText("Syllabus").scrollTo().click() } + /** + * Opens the files tab in the course browser. + */ + fun openFilesTab() { + scrollOpen("Files", scrollPosition = magicNumberForScroll) + } + /** * Opens the modules tab in the course browser. */ diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/FileListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/FileListPage.kt index b343143c9f..475e1925f0 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/FileListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/FileListPage.kt @@ -84,6 +84,20 @@ class FileListPage(val searchable: Searchable) : BasePage(R.id.fileListPage) { onView(matcher).check(doesNotExist()) } + /** + * Clicks the add (+) FAB button to reveal upload/folder options. + */ + fun clickAddButton() { + onView(allOf(withId(R.id.addFab), isDisplayed())).perform(click()) + } + + /** + * Clicks the upload file FAB button. + */ + fun clickUploadFileButton() { + onView(allOf(withId(R.id.addFileFab), isDisplayed())).perform(click()) + } + /** * Selects the specified item in the file list. * diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/StudentContextPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/StudentContextPage.kt index 2809cf3af9..e8e7d2d21e 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/StudentContextPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/StudentContextPage.kt @@ -12,6 +12,7 @@ * WITHOUT 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("unused") @@ -20,6 +21,7 @@ package com.instructure.teacher.ui.pages.classic import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus @@ -63,6 +65,23 @@ class StudentContextPage : PersonContextPage() { onView(withId(R.id.gradeBeforePosting)).assertHasText(grade) } + /** + * Asserts the student's grade after posting on the Student Context page. + * + * @param grade The expected grade after posting. + */ + fun assertStudentGradeAfterPosting(grade: String) { + onView(withId(R.id.gradeAfterPosting)).assertHasText(grade) + } + + /** + * Asserts that the 'Grade after posting' container is not displayed, which is the case + * when the grade is posted (both before/after values are equal and only one box is shown). + */ + fun assertGradeAfterPostingNotDisplayed() { + onView(withId(R.id.gradeAfterPostingContainer)).assertNotDisplayed() + } + /** * Asserts the student's submission count on the Student Context page. * diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/AssignmentSubmissionListPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/AssignmentSubmissionListPage.kt index 812b0041e1..ff114b3140 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/AssignmentSubmissionListPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/AssignmentSubmissionListPage.kt @@ -15,7 +15,6 @@ */ package com.instructure.teacher.ui.pages.compose - import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals @@ -105,6 +104,7 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) */ fun clearSearch() { composeTestRule.onNodeWithTag("clearButton").performClick() + composeTestRule.waitForIdle() } /** @@ -130,6 +130,7 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) */ fun clickOnPostPolicies() { composeTestRule.onNodeWithTag("postPolicyButton").performClick() + composeTestRule.waitForIdle() } /** @@ -167,6 +168,7 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) ) .performScrollTo() .performClick() + composeTestRule.waitForIdle() } /** @@ -180,6 +182,7 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) ) .performScrollTo() .performClick() + composeTestRule.waitForIdle() } /** @@ -190,8 +193,10 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) composeTestRule.onNode( hasTestTag("statusCheckBox") and hasAnySibling(hasText("Needs Grading")), useUnmergedTree = true - ).performScrollTo() + ) + .performScrollTo() .performClick() + composeTestRule.waitForIdle() } /** @@ -211,7 +216,8 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) ) ), useUnmergedTree = true - ).performScrollTo() + ) + .performScrollTo() .performClick() composeTestRule.waitForIdle() } @@ -267,6 +273,10 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) * @param expectedCount */ fun assertHasSubmission(expectedCount: Int = 1) { + composeTestRule.waitUntil(timeoutMillis = 10000) { + composeTestRule.onAllNodes(hasTestTag("submissionListItem"), useUnmergedTree = true) + .fetchSemanticsNodes().isNotEmpty() + } composeTestRule.onAllNodes(hasTestTag("submissionListItem"), useUnmergedTree = true) .assertCountEquals(expectedCount) } @@ -327,6 +337,7 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) fun clickAddMessage() { composeTestRule.onNodeWithTag("addMessageButton") .performClick() + composeTestRule.waitForIdle() } /** @@ -383,6 +394,7 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) ) .performScrollTo() .performClick() + composeTestRule.waitForIdle() } /** @@ -399,6 +411,7 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) ) .performScrollTo() .performClick() + composeTestRule.waitForIdle() } /** @@ -420,6 +433,7 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) ) .performScrollTo() .performClick() + composeTestRule.waitForIdle() } /** @@ -440,6 +454,7 @@ class AssignmentSubmissionListPage(private val composeTestRule: ComposeTestRule) composeTestRule.onNodeWithText(sortOrderName, useUnmergedTree = true) .performScrollTo() .performClick() + composeTestRule.waitForIdle() } /** diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/SpeedGraderPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/SpeedGraderPage.kt index f12c3e5141..1fec868ba4 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/SpeedGraderPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/SpeedGraderPage.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeUp import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.espresso.matcher.ViewMatchers.hasDescendant @@ -168,7 +170,8 @@ class SpeedGraderPage(private val composeTestRule: ComposeTestRule) : BasePage() } /** - * Selects the "Grades & Rubric" tab. + * Selects the specified tab (e.g., "Grades", "Comments") in the SpeedGrader page. + * @param tabTitle The tab's name which will be selected. */ fun selectTab(tabTitle: String) { composeTestRule.onNode(hasTestTag("speedGraderTab-${tabTitle}"), useUnmergedTree = true) @@ -308,7 +311,7 @@ class SpeedGraderPage(private val composeTestRule: ComposeTestRule) : BasePage() composeTestRule.waitForIdle() swipeUpGradeAndRubric() waitForView(withId(R.id.recordAudioButton)).click() - Thread.sleep(3000) // Let the audio recording go for a bit + Thread.sleep(5000) // Let the audio recording go for a bit waitForView(withId(R.id.stopButton)).click() waitForView(withId(R.id.sendAudioButton)).click() composeTestRule.waitForIdle() @@ -333,7 +336,7 @@ class SpeedGraderPage(private val composeTestRule: ComposeTestRule) : BasePage() composeTestRule.waitForIdle() swipeUpGradeAndRubric() waitForView(withId(R.id.startRecordingButton)).click() - Thread.sleep(3000) // Let the video recording go for a bit + Thread.sleep(5000) // Let the video recording go for a bit waitForView(withId(R.id.endRecordingButton)).click() waitForView(withId(R.id.sendButton)).click() composeTestRule.waitForIdle() @@ -508,11 +511,47 @@ class SpeedGraderPage(private val composeTestRule: ComposeTestRule) : BasePage() .check(webMatches(getText(), Matchers.containsString(student.shortName))) } + /** + * Asserts the current student name displayed in the SpeedGrader page. + * + * @param studentName The expected name of the current student to be displayed. + */ + fun assertCurrentStudent(studentName: String) { + composeTestRule.onNode(hasTestTag("speedGraderUserName") and hasText(studentName), useUnmergedTree = true).assertIsDisplayed() + } + + /** + * Asserts the current student submission status. + * + * @param status The expected submission status to be displayed. + */ + fun assertCurrentStudentStatus(status: String) { + composeTestRule.onNode(hasTestTag("submissionStatusLabel") and hasText(status), useUnmergedTree = true).assertIsDisplayed() + } + + /** + * Swipes left to navigate to the next student in the SpeedGrader page. + */ + fun swipeToNextStudent() { + composeTestRule.onNodeWithTag("speedGraderPager") + .performTouchInput { swipeLeft() } + composeTestRule.waitForIdle() + } + + /** + * Swipes right to navigate to the previous student in the SpeedGrader page. + */ + fun swipeToPreviousStudent() { + composeTestRule.onNodeWithTag("speedGraderPager") + .performTouchInput { swipeRight() } + composeTestRule.waitForIdle() + } + /** * Clicks the back button. */ fun clickBackButton() { - composeTestRule.onNode(hasTestTag("navigationButton")).performClick() + composeTestRule.onNode(hasTestTag("navigationButton") and hasAnyAncestor(hasTestTag("speedGraderAppBar")), useUnmergedTree = true).performClick() composeTestRule.waitForIdle() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt index ffa88fdb1e..8fde5058c3 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/utils/TeacherComposeTest.kt @@ -15,10 +15,10 @@ * * */ - package com.instructure.teacher.ui.utils import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.instructure.canvas.espresso.common.pages.VideoPlayerPage import com.instructure.canvas.espresso.common.pages.compose.AssignmentListPage import com.instructure.canvas.espresso.common.pages.compose.CalendarEventCreateEditPage import com.instructure.canvas.espresso.common.pages.compose.CalendarEventDetailsPage @@ -37,7 +37,6 @@ import com.instructure.teacher.ui.pages.compose.AssignmentSubmissionListPage import com.instructure.teacher.ui.pages.compose.ProgressPage import com.instructure.teacher.ui.pages.compose.SpeedGraderGradePage import com.instructure.teacher.ui.pages.compose.SpeedGraderPage - import org.junit.Rule abstract class TeacherComposeTest : TeacherTest() { @@ -63,4 +62,5 @@ abstract class TeacherComposeTest : TeacherTest() { val inboxSignatureSettingsPage = InboxSignatureSettingsPage(composeTestRule) val speedGraderPage = SpeedGraderPage(composeTestRule) val speedGraderGradePage = SpeedGraderGradePage(composeTestRule) + val videoPlayerPage = VideoPlayerPage() } \ No newline at end of file diff --git a/apps/teacher/src/main/AndroidManifest.xml b/apps/teacher/src/main/AndroidManifest.xml index 093b17596a..c3f7fc090e 100644 --- a/apps/teacher/src/main/AndroidManifest.xml +++ b/apps/teacher/src/main/AndroidManifest.xml @@ -58,6 +58,10 @@ android:networkSecurityConfig="@xml/network_security_config" tools:replace="android:icon"> + + diff --git a/apps/teacher/src/main/java/com/instructure/teacher/PSPDFKit/AnnotationComments/AnnotationCommentListFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/PSPDFKit/AnnotationComments/AnnotationCommentListFragment.kt index 7521e40495..8ece81d107 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/PSPDFKit/AnnotationComments/AnnotationCommentListFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/PSPDFKit/AnnotationComments/AnnotationCommentListFragment.kt @@ -95,6 +95,13 @@ class AnnotationCommentListFragment : BaseListFragment< setupToolbar() presenter.loadData(false) setupCommentInput() + setupWindowInsets() + } + + private fun setupWindowInsets() = with(binding) { + toolbar.applyTopSystemBarInsets() + annotationCommentsRecyclerView.applyImeAndSystemBarInsets() + commentInputContainer.applyImeAndSystemBarInsets() } fun setupToolbar() { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/FullscreenActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/FullscreenActivity.kt index d466bae92f..acf03ee5d1 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/activities/FullscreenActivity.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/FullscreenActivity.kt @@ -20,6 +20,9 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Parcelable +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.managers.GroupManager @@ -53,6 +56,8 @@ open class FullscreenActivity : BaseAppCompatActivity(), FullScreenInteractions super.onCreate(savedInstanceState) setContentView(binding.root) + setupWindowInsets() + if (savedInstanceState == null) { mRoute = intent.extras!!.getParcelable(Route.ROUTE) mRoute?.let { handleRoute(it) } @@ -63,6 +68,45 @@ open class FullscreenActivity : BaseAppCompatActivity(), FullScreenInteractions } } + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.container) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftPadding = maxOf(navigationBars.left, displayCutout.left) + val rightPadding = maxOf(navigationBars.right, displayCutout.right) + + view.setPadding( + leftPadding, + 0, + rightPadding, + 0 + ) + + // Consume horizontal insets so child ComposeViews don't apply them again + WindowInsetsCompat.Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + Insets.of( + 0, // Consume left + navigationBars.top, + 0, // Consume right + navigationBars.bottom + ) + ) + .setInsets( + WindowInsetsCompat.Type.displayCutout(), + Insets.of( + 0, // Consume left + displayCutout.top, + 0, // Consume right + displayCutout.bottom + ) + ) + .build() + } + } + override fun onDestroy() { super.onDestroy() groupApiCall?.cancel() diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt index dcd50bd5c6..71d0a22c36 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt @@ -26,14 +26,19 @@ import android.util.Log import android.view.View import android.view.accessibility.AccessibilityNodeInfo import android.widget.CompoundButton +import android.widget.RelativeLayout import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.IdRes import androidx.annotation.PluralsRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.Toolbar +import androidx.core.graphics.Insets import androidx.core.view.GravityCompat import androidx.core.view.MenuItemCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -88,6 +93,7 @@ import com.instructure.pandautils.utils.AppType import com.instructure.pandautils.utils.CanvasFont import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.LocaleUtils import com.instructure.pandautils.utils.ProfileUtils @@ -99,6 +105,7 @@ import com.instructure.pandautils.utils.isAccessibilityEnabled import com.instructure.pandautils.utils.items import com.instructure.pandautils.utils.setGone import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.toPx import com.instructure.pandautils.utils.toast import com.instructure.teacher.BuildConfig import com.instructure.teacher.R @@ -215,6 +222,7 @@ class InitActivity : BasePresenterActivity On Create") - val masqueradingUserId: Long = intent.getLongExtra(Const.QR_CODE_MASQUERADE_ID, 0L) + val intentMasqueradeId = intent.getLongExtra(Const.QR_CODE_MASQUERADE_ID, 0L) + val masqueradingUserId: Long = when { + intentMasqueradeId != 0L -> intentMasqueradeId + !ApiPrefs.isMasquerading && ApiPrefs.isMasqueradingFromQRCode && ApiPrefs.masqueradeId > 0L -> ApiPrefs.masqueradeId + else -> 0L + } if (masqueradingUserId != 0L) { MasqueradeHelper.startMasquerading(masqueradingUserId, ApiPrefs.domain, InitActivity::class.java) finish() @@ -238,6 +251,8 @@ class InitActivity : BasePresenterActivity + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = true + } + } else if (!ColorKeeper.darkTheme) { + window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = false + } + } + } + } + + private fun setupWindowInsets() = with(binding) { + ViewCompat.setOnApplyWindowInsetsListener(container) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val displayCutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + // Apply both navigation bar and display cutout insets + // This ensures content is not hidden behind the navigation bar OR the hole punch camera + val leftPadding = maxOf(navigationBars.left, displayCutout.left) + val rightPadding = maxOf(navigationBars.right, displayCutout.right) + + view.setPadding( + leftPadding, + 0, + rightPadding, + 0 + ) + + // Consume horizontal insets so child ComposeViews don't apply them again + WindowInsetsCompat.Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + Insets.of( + 0, // Consume left + navigationBars.top, + 0, // Consume right + navigationBars.bottom + ) + ) + .setInsets( + WindowInsetsCompat.Type.displayCutout(), + Insets.of( + 0, // Consume left + displayCutout.top, + 0, // Consume right + displayCutout.bottom + ) + ) + .build() + } + + ViewCompat.setOnApplyWindowInsetsListener(bottomBar) { view, insets -> + val navigationBars = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + bottomBarContainer.updateLayoutParams { + height = 56.toPx + navigationBars.bottom + } + insets + } + + ViewCompat.setOnApplyWindowInsetsListener(navigationDrawerBinding.navigationDrawer) { view, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + // In landscape, navigation bar is on the left side (where drawer opens from) + // Apply left padding only to prevent overlap with nav bar + view.setPadding(insets.left, insets.top, 0, 0) + } else { + view.setPadding(insets.left, insets.top, insets.right, insets.bottom) + } + windowInsets + } } private fun requestNotificationsPermission() { @@ -460,6 +564,24 @@ class InitActivity : BasePresenterActivity + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = true + } + } + } + + override fun onDrawerClosed(drawerView: View) { + super.onDrawerClosed(drawerView) + // Restore status bar icons to light only in light mode (for dark toolbar) + if (!ColorKeeper.darkTheme) { + window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = false + } + } } }) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/InternalWebViewActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/InternalWebViewActivity.kt index fe89585e7c..4cf17147d1 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/activities/InternalWebViewActivity.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/InternalWebViewActivity.kt @@ -19,10 +19,15 @@ package com.instructure.teacher.activities import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.instructure.canvasapi2.models.CanvasContext import com.instructure.interactions.router.Route import com.instructure.pandautils.activities.BasePresenterActivity import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.EdgeToEdgeHelper +import com.instructure.pandautils.utils.applyBottomSystemBarInsets import com.instructure.teacher.R import com.instructure.teacher.factory.InternalWebViewPresenterFactory import com.instructure.teacher.fragments.InternalWebViewFragment @@ -39,10 +44,26 @@ class InternalWebViewActivity : BasePresenterActivity(R.id.internalWebViewContainer) + ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.updatePadding( + left = insets.left, + right = insets.right + ) + windowInsets + } + } + override fun onReadySetGo(presenter: InternalWebViewPresenter) = setupViews() override fun onPresenterPrepared(presenter: InternalWebViewPresenter) = Unit diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/MasterDetailActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/MasterDetailActivity.kt index b5b653c514..d05a3977b3 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/activities/MasterDetailActivity.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/MasterDetailActivity.kt @@ -24,6 +24,8 @@ import android.os.Bundle import android.os.Parcelable import android.view.View import android.view.ViewTreeObserver +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import androidx.percentlayout.widget.PercentLayoutHelper import com.instructure.canvasapi2.StatusCallback @@ -37,6 +39,7 @@ import com.instructure.interactions.MasterDetailInteractions import com.instructure.interactions.router.Route import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.interfaces.NavigationCallbacks +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.setGone @@ -67,10 +70,13 @@ class MasterDetailActivity : BaseAppCompatActivity(), MasterDetailInteractions { } override fun onCreate(savedInstanceState: Bundle?) = with(binding) { + EdgeToEdgeHelper.enableEdgeToEdge(this@MasterDetailActivity) super.onCreate(savedInstanceState) setContentView(binding.root) + setupWindowInsets() + mRoute = intent.extras!!.getParcelable(Route.ROUTE) if (mRoute == null) { @@ -119,6 +125,20 @@ class MasterDetailActivity : BaseAppCompatActivity(), MasterDetailInteractions { } } + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.rootView) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding( + systemBars.left, + 0, + systemBars.right, + 0 + ) + // Don't consume the insets - let them propagate to child fragments + insets + } + } + private fun setupWithCanvasContext(course: Course?) { if(course != null) { //we have a route, get the fragment diff --git a/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt b/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt index 4673925316..1d662d7c8c 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/activities/RouteValidatorActivity.kt @@ -118,6 +118,7 @@ class RouteValidatorActivity : BaseCanvasActivity() { val intent = if (tokenResponse.realUser != null && tokenResponse.user != null) { // We need to set the masquerade request to the user (masqueradee), the real user it the admin user currently masquerading ApiPrefs.isMasqueradingFromQRCode = true + ApiPrefs.masqueradeId = tokenResponse.user!!.id val extras = Bundle() extras.putLong(Const.QR_CODE_MASQUERADE_ID, tokenResponse.user!!.id) LoginActivity.createLaunchApplicationMainActivityIntent(this@RouteValidatorActivity, extras) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/adapters/StudentContextFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/adapters/StudentContextFragment.kt index 0a7ee54dc6..32e6ea5ca0 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/adapters/StudentContextFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/adapters/StudentContextFragment.kt @@ -50,6 +50,9 @@ import com.instructure.pandautils.utils.BooleanArg import com.instructure.pandautils.utils.LongArg import com.instructure.pandautils.utils.ProfileUtils import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarMargin +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.asStateList import com.instructure.pandautils.utils.children import com.instructure.pandautils.utils.color @@ -150,6 +153,10 @@ class StudentContextFragment : PresenterFragment Unit) { var showFilterDialog by rememberSaveable { mutableStateOf(false) } - Scaffold( + CanvasScaffold( backgroundColor = colorResource(id = R.color.backgroundLightest), topBar = { - CanvasAppBar( + CanvasThemedAppBar( title = stringResource(R.string.submissions), subtitle = uiState.filtersUiState.assignmentName, navigationActionClick = { navigationIconClick() }, navIconContentDescription = stringResource(R.string.back), navIconRes = R.drawable.ic_back_arrow, backgroundColor = uiState.filtersUiState.courseColor, - textColor = colorResource(id = R.color.textLightest), + contentColor = colorResource(id = R.color.textLightest), actions = { IconButton( modifier = Modifier.testTag("filterButton"), @@ -180,6 +184,7 @@ private fun SubmissionListContent( }) Box( modifier = Modifier + .windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) .fillMaxSize() .pullRefresh(pullRefreshState) ) { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/files/details/FileDetailsFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/files/details/FileDetailsFragment.kt index c307bfdab9..c7807e0b92 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/files/details/FileDetailsFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/files/details/FileDetailsFragment.kt @@ -28,6 +28,7 @@ import androidx.fragment.app.viewModels import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.tryOrNull import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.utils.BooleanArg import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.makeBundle @@ -46,6 +47,7 @@ class FileDetailsFragment : BaseCanvasFragment() { private val viewModel: FileDetailsViewModel by viewModels() private val binding by viewBinding(FragmentFileDetailsBinding::bind) + private var isInModulesPager: Boolean by BooleanArg(key = IS_IN_MODULES_PAGER, default = false) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return inflater.inflate(R.layout.fragment_file_details, container, false) @@ -75,17 +77,17 @@ class FileDetailsFragment : BaseCanvasFragment() { fileData.url, toolbarColor, fileData.editableFile, - true + isInModulesPager ) is FileViewData.Media -> ViewMediaFragment.newInstance( - Uri.parse(fileData.url), - fileData.thumbnailUrl, - fileData.contentType, - fileData.displayName, - true, - toolbarColor, - fileData.editableFile + uri = Uri.parse(fileData.url), + thumbnailUrl = fileData.thumbnailUrl, + contentType = fileData.contentType, + displayName = fileData.displayName, + toolbarColor = toolbarColor, + editableFile = fileData.editableFile, + isInModulesPager = isInModulesPager ) is FileViewData.Image -> ViewImageFragment.newInstance( @@ -95,7 +97,7 @@ class FileDetailsFragment : BaseCanvasFragment() { true, toolbarColor, fileData.editableFile, - true + isInModulesPager ) is FileViewData.Html -> ViewHtmlFragment.newInstance( @@ -104,7 +106,7 @@ class FileDetailsFragment : BaseCanvasFragment() { fileData.fileName, toolbarColor, fileData.editableFile, - true + isInModulesPager ) ) @@ -116,14 +118,19 @@ class FileDetailsFragment : BaseCanvasFragment() { R.drawable.ic_document, toolbarColor, fileData.editableFile, - true + isInModulesPager ) } } companion object { - fun makeBundle(canvasContext: CanvasContext, fileUrl: String): Bundle { - return canvasContext.makeBundle { putString(Const.FILE_URL, fileUrl) } + private const val IS_IN_MODULES_PAGER = "isInModulesPager" + + fun makeBundle(canvasContext: CanvasContext, fileUrl: String, isInModulesPager: Boolean = false): Bundle { + return canvasContext.makeBundle { + putString(Const.FILE_URL, fileUrl) + putBoolean(IS_IN_MODULES_PAGER, isInModulesPager) + } } fun newInstance(bundle: Bundle) = FileDetailsFragment().withArgs(bundle) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/files/search/FileSearchFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/files/search/FileSearchFragment.kt index 8b65117d7a..57c784da6c 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/files/search/FileSearchFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/files/search/FileSearchFragment.kt @@ -19,6 +19,9 @@ package com.instructure.teacher.features.files.search import android.view.View import androidx.appcompat.widget.PopupMenu import androidx.core.graphics.ColorUtils +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.models.CanvasContext @@ -34,6 +37,8 @@ import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.NullableParcelableArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyDisplayCutoutInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.getDrawableCompat import com.instructure.pandautils.utils.isUser @@ -107,6 +112,22 @@ class FileSearchFragment : BaseSyncFragment< override fun onReadySetGo(presenter: FileSearchPresenter) { if (recyclerView.adapter == null) binding.fileSearchRecyclerView.adapter = createAdapter() setupViews() + setupWindowInsets() + } + + private fun setupWindowInsets() = with(binding) { + root.applyDisplayCutoutInsets() + searchHeader.applyTopSystemBarInsets() + ViewCompat.setOnApplyWindowInsetsListener(fileSearchRecyclerView) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + view.updatePadding(bottom = maxOf(systemBars.bottom, ime.bottom)) + insets + } + fileSearchRecyclerView.clipToPadding = false + if (fileSearchRecyclerView.isAttachedToWindow) { + ViewCompat.requestApplyInsets(fileSearchRecyclerView) + } } override fun createAdapter(): FileSearchAdapter = searchAdapter diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/list/TeacherInboxRouter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/list/TeacherInboxRouter.kt index 29d16bcd98..267d810abc 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/list/TeacherInboxRouter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/inbox/list/TeacherInboxRouter.kt @@ -43,7 +43,7 @@ import com.instructure.teacher.utils.setupBackButtonAsBackPressedOnly class TeacherInboxRouter(private val activity: FragmentActivity, private val fragment: Fragment) : InboxRouter { override fun openConversation(conversation: Conversation, scope: InboxApi.Scope) { - val route = InboxDetailsFragment.makeRoute(conversation.id, conversation.workflowState == Conversation.WorkflowState.UNREAD) + val route = InboxDetailsFragment.makeRoute(conversation.id, conversation.workflowState == Conversation.WorkflowState.UNREAD, scope) RouteMatcher.route(activity, route) } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt index b764dffaa6..8c32a49f18 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/list/ui/ModuleListView.kt @@ -30,6 +30,9 @@ import com.instructure.pandarecycler.PaginatedScrollListener import com.instructure.pandautils.features.progress.ProgressDialogFragment import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEntity import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyDisplayCutoutInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.showThemed import com.instructure.teacher.R import com.instructure.teacher.databinding.FragmentModuleListBinding @@ -134,6 +137,7 @@ class ModuleListView( init { // Toolbar setup binding.toolbar.apply { + applyTopSystemBarInsets() subtitle = course.name setupBackButton(activity) ViewStyler.themeToolbarColored(activity, this, course) @@ -191,14 +195,20 @@ class ModuleListView( } } + // Apply display cutout insets to root view to prevent content from extending behind camera cutout + binding.root.applyDisplayCutoutInsets() + binding.recyclerView.apply { layoutManager = this@ModuleListView.layoutManager adapter = this@ModuleListView.adapter addOnScrollListener(scrollListener) } - binding.swipeRefreshLayout.setOnRefreshListener { - consumer?.accept(ModuleListEvent.PullToRefresh) + binding.swipeRefreshLayout.apply { + setOnRefreshListener { + consumer?.accept(ModuleListEvent.PullToRefresh) + } + applyBottomSystemBarInsets() } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleProgressionFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleProgressionFragment.kt index 37c4a66c69..6ec7802cb2 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleProgressionFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleProgressionFragment.kt @@ -34,6 +34,7 @@ import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.LongArg import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.StringArg +import com.instructure.pandautils.utils.applyBottomSystemBarMargin import com.instructure.pandautils.utils.makeBundle import com.instructure.pandautils.utils.setHidden import com.instructure.teacher.R @@ -68,6 +69,10 @@ class ModuleProgressionFragment : BaseCanvasFragment() { binding.lifecycleOwner = this binding.viewModel = viewModel + binding.previous.applyBottomSystemBarMargin() + binding.moduleName.applyBottomSystemBarMargin() + binding.next.applyBottomSystemBarMargin() + viewModel.data.observe(viewLifecycleOwner) { setupPager(it) } @@ -107,19 +112,19 @@ class ModuleProgressionFragment : BaseCanvasFragment() { private fun createFragment(item: ModuleItemViewData) = when (item) { is ModuleItemViewData.Page -> PageDetailsFragment.newInstance( - canvasContext, PageDetailsFragment.makeBundle(item.pageUrl) + canvasContext, PageDetailsFragment.makeBundle(item.pageUrl, isInModulesPager = true) ) is ModuleItemViewData.Assignment -> AssignmentDetailsFragment.newInstance( - canvasContext as Course, AssignmentDetailsFragment.makeBundle(item.assignmentId) + canvasContext as Course, AssignmentDetailsFragment.makeBundle(item.assignmentId, isInModulesPager = true) ) is ModuleItemViewData.Discussion -> DiscussionDetailsWebViewFragment.newInstance( - DiscussionDetailsWebViewFragment.makeRoute(canvasContext, item.discussionTopicHeaderId) + DiscussionDetailsWebViewFragment.makeRoute(canvasContext, item.discussionTopicHeaderId, isInModulesPager = true) )!! is ModuleItemViewData.Quiz -> QuizDetailsFragment.newInstance( - canvasContext as Course, QuizDetailsFragment.makeBundle(item.quizId) + canvasContext as Course, QuizDetailsFragment.makeBundle(item.quizId, isInModulesPager = true) ) is ModuleItemViewData.External -> InternalWebViewFragment.newInstance( @@ -134,7 +139,7 @@ class ModuleProgressionFragment : BaseCanvasFragment() { ) is ModuleItemViewData.File -> FileDetailsFragment.newInstance( - FileDetailsFragment.makeBundle(canvasContext, item.fileUrl) + FileDetailsFragment.makeBundle(canvasContext, item.fileUrl, isInModulesPager = true) ) } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/postpolicies/ui/PostPolicyFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/postpolicies/ui/PostPolicyFragment.kt index 506f4eda7a..ae48966260 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/postpolicies/ui/PostPolicyFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/postpolicies/ui/PostPolicyFragment.kt @@ -21,6 +21,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter @@ -33,6 +36,7 @@ import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.withArgs import com.instructure.teacher.R @@ -56,6 +60,18 @@ class PostPolicyFragment : BaseCanvasFragment() { val titles = listOf(getString(R.string.postGradesTab), getString(R.string.hideGradesTab)) binding.postPolicyPager.adapter = PostPolicyPagerAdapter(assignment, childFragmentManager, titles) binding.postPolicyTabLayout.setupWithViewPager(binding.postPolicyPager, true) + + setupWindowInsets() + } + + private fun setupWindowInsets() = with(binding) { + postPolicyToolbar.applyTopSystemBarInsets() + + ViewCompat.setOnApplyWindowInsetsListener(postPolicyPager) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = systemBars.bottom) + insets + } } override fun onResume() { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/speedgrader/PdfTeacherSubmissionView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/speedgrader/PdfTeacherSubmissionView.kt index d7c64a17c7..394919a4f4 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/speedgrader/PdfTeacherSubmissionView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/speedgrader/PdfTeacherSubmissionView.kt @@ -50,8 +50,9 @@ import com.instructure.teacher.view.AnnotationCommentDeleteAcknowledged import com.instructure.teacher.view.AnnotationCommentDeleted import com.instructure.teacher.view.AnnotationCommentEdited import com.pspdfkit.preferences.PSPDFKitPreferences +import com.pspdfkit.ui.annotations.OnAnnotationCreationModeChangeListener +import com.pspdfkit.ui.annotations.OnAnnotationEditingModeChangeListener import com.pspdfkit.ui.inspector.PropertyInspectorCoordinatorLayout -import com.pspdfkit.ui.special_mode.manager.AnnotationManager import com.pspdfkit.ui.toolbar.ToolbarCoordinatorLayout import kotlinx.coroutines.Job import okhttp3.ResponseBody @@ -69,8 +70,8 @@ class PdfTeacherSubmissionView( private val studentAnnotationSubmit: Boolean = false, private val studentAnnotationView: Boolean = false ) : PdfSubmissionView(activity, studentAnnotationView, courseId), - AnnotationManager.OnAnnotationCreationModeChangeListener, - AnnotationManager.OnAnnotationEditingModeChangeListener { + OnAnnotationCreationModeChangeListener, + OnAnnotationEditingModeChangeListener { private val binding: ViewPdfTeacherSubmissionBinding diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/speedgrader/commentlibrary/CommentLibraryFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/speedgrader/commentlibrary/CommentLibraryFragment.kt index 874964767a..f3fef0de3d 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/speedgrader/commentlibrary/CommentLibraryFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/speedgrader/commentlibrary/CommentLibraryFragment.kt @@ -27,6 +27,8 @@ import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.LongArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyImeAndSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.teacher.databinding.FragmentCommentLibraryBinding import com.instructure.teacher.utils.setupCloseButton import dagger.hilt.android.AndroidEntryPoint @@ -56,9 +58,17 @@ class CommentLibraryFragment : BaseCanvasFragment() { } } + setupWindowInsets() + return binding.root } + private fun setupWindowInsets() = with(binding) { + commentLibraryToolbar.applyTopSystemBarInsets() + commentLibraryRecyclerView.applyImeAndSystemBarInsets() + commentInputContainer.root.applyImeAndSystemBarInsets() + } + override fun onResume() { super.onResume() setupToolbar() diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/edit/EditSyllabusView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/edit/EditSyllabusView.kt index 16a964a8ec..d650f70f5f 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/edit/EditSyllabusView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/edit/EditSyllabusView.kt @@ -28,6 +28,7 @@ import com.instructure.canvasapi2.models.Course import com.instructure.pandautils.dialogs.UnsavedChangesExitDialog import com.instructure.pandautils.discussions.DiscussionUtils import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.views.CanvasWebView import com.instructure.teacher.R import com.instructure.teacher.databinding.FragmentEditSyllabusBinding @@ -58,6 +59,7 @@ class EditSyllabusView( fun setupToolbar() = with(binding) { val activity = context as? FragmentActivity + toolbar.applyTopSystemBarInsets() toolbar.setupCloseButton { activity?.onBackPressed() } toolbar.setupMenu(R.menu.menu_edit_syllabus) { menuItem -> when (menuItem.itemId) { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusRepositoryFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusRepositoryFragment.kt index fe0e59eeaf..ca6fca3fd8 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusRepositoryFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusRepositoryFragment.kt @@ -17,6 +17,7 @@ package com.instructure.teacher.features.syllabus.ui +import android.os.Bundle import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course import com.instructure.pandautils.utils.Const @@ -31,6 +32,11 @@ class SyllabusRepositoryFragment : SyllabusFragment() { @Inject lateinit var syllabusRepository: SyllabusRepository + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + retainInstance = false + } + override fun getRepository() = syllabusRepository companion object { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusView.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusView.kt index 4fb9f3c288..291dc295a0 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusView.kt @@ -33,6 +33,9 @@ import com.instructure.canvasapi2.utils.exhaustive import com.instructure.interactions.router.Route import com.instructure.pandautils.features.calendarevent.details.EventFragment import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyDisplayCutoutInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.getDrawableCompat import com.instructure.pandautils.utils.onClick @@ -89,6 +92,12 @@ class SyllabusView( } init { + binding.toolbar.applyTopSystemBarInsets() + binding.swipeRefreshLayout.applyBottomSystemBarInsets() + + // Apply display cutout insets to root view to prevent content from extending behind camera cutout + binding.root.applyDisplayCutoutInsets() + binding.toolbar.setupMenu(R.menu.menu_edit_generic) { consumer?.accept(SyllabusEvent.EditClicked) } setEditVisibility(false) ViewStyler.themeToolbarColored(activity, binding.toolbar, canvasContext) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/AssigneeListFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/AssigneeListFragment.kt index 0e770799f8..142176f729 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/AssigneeListFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/AssigneeListFragment.kt @@ -22,6 +22,10 @@ import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan import android.view.View import android.widget.TextView +import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.instructure.canvasapi2.models.CanvasComparable @@ -33,7 +37,13 @@ import com.instructure.pandautils.analytics.SCREEN_VIEW_ASSIGNEE_LIST import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.fragments.BaseExpandableSyncFragment -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.IntArg +import com.instructure.pandautils.utils.ParcelableArrayListArg +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.bind +import com.instructure.pandautils.utils.setVisible import com.instructure.pandautils.views.EmptyView import com.instructure.teacher.R import com.instructure.teacher.adapters.AssigneeListAdapter @@ -42,7 +52,11 @@ import com.instructure.teacher.factory.AssigneeListPresenterFactory import com.instructure.teacher.holders.AssigneeViewHolder import com.instructure.teacher.models.AssigneeCategory import com.instructure.teacher.presenters.AssigneeListPresenter -import com.instructure.teacher.utils.* +import com.instructure.teacher.utils.EditDateGroups +import com.instructure.teacher.utils.RecyclerViewUtils +import com.instructure.teacher.utils.getColorCompat +import com.instructure.teacher.utils.setupCloseButton +import com.instructure.teacher.utils.setupMenu import com.instructure.teacher.viewinterface.AssigneeListView @ScreenView(SCREEN_VIEW_ASSIGNEE_LIST) @@ -72,7 +86,16 @@ class AssigneeListFragment : BaseExpandableSyncFragment< override fun withPagination() = false override fun perPageCount() = ApiPrefs.perPageCount override fun getPresenterFactory() = AssigneeListPresenterFactory(mDateGroups, mTargetIdx, sections, groups, students) - override fun onCreateView(view: View) {} + override fun onCreateView(view: View) { + view.findViewById(R.id.toolbar)?.applyTopSystemBarInsets() + + val recyclerView = view.findViewById(R.id.recyclerView) + ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } + } private fun performSave() { presenter.save() diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/AttendanceListFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/AttendanceListFragment.kt index 40a728eaeb..067060803d 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/AttendanceListFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/AttendanceListFragment.kt @@ -51,6 +51,7 @@ import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.enableAlgorithmicDarkening import com.instructure.pandautils.utils.isTablet @@ -113,6 +114,7 @@ class AttendanceListFragment : BaseSyncFragment< private fun setupViews() = with(binding) { webView.enableAlgorithmicDarkening() + toolbar.applyTopSystemBarInsets() toolbar.setupMenu(R.menu.menu_attendance) { menuItem -> when(menuItem.itemId) { R.id.menuFilterSections -> { /* Do Nothing */ } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ChooseRecipientsFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ChooseRecipientsFragment.kt index 0088707ca9..f7b5762879 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ChooseRecipientsFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ChooseRecipientsFragment.kt @@ -21,6 +21,9 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Recipient @@ -31,6 +34,7 @@ import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.fragments.BaseSyncFragment import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.nonNullArgs import com.instructure.teacher.R import com.instructure.teacher.adapters.ChooseMessageRecipientRecyclerAdapter @@ -106,12 +110,23 @@ class ChooseRecipientsFragment : BaseSyncFragment(R.id.menuDone).setTextColor(ThemePrefs.textButtonColor) + + view.findViewById(R.id.toolbar).applyTopSystemBarInsets() + return view } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { swipeRefreshLayoutContainerBinding = RecyclerSwipeRefreshLayoutBinding.bind(view) super.onViewCreated(view, savedInstanceState) + + mRecyclerView?.let { recyclerView -> + ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } + } } override fun layoutResId(): Int = R.layout.fragment_choose_recipients diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseBrowserFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseBrowserFragment.kt index 6c6389925d..b4391cd825 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseBrowserFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseBrowserFragment.kt @@ -22,6 +22,9 @@ import android.net.Uri import android.view.MenuItem import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.google.android.material.appbar.AppBarLayout import com.instructure.canvasapi2.models.CanvasContext @@ -43,9 +46,12 @@ import com.instructure.pandautils.fragments.BaseSyncFragment import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.Const.CANVAS_STUDENT_ID import com.instructure.pandautils.utils.Const.MARKET_URI_PREFIX +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.a11yManager +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyDisplayCutoutInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.isSwitchAccessEnabled import com.instructure.pandautils.utils.isTablet @@ -143,6 +149,9 @@ class CourseBrowserFragment : BaseSyncFragment< ) appBarLayout.addOnOffsetChangedListener(this@CourseBrowserFragment) collapsingToolbarLayout.isTitleEnabled = false + + // Apply display cutout insets to root view to prevent content from extending behind camera cutout + rootView.applyDisplayCutoutInsets() } override fun onReadySetGo(presenter: CourseBrowserPresenter) { @@ -162,6 +171,14 @@ class CourseBrowserFragment : BaseSyncFragment< binding.courseBrowserSubtitle.text = (presenter.canvasContext as? Course)?.term?.name.orEmpty() courseBrowserHeader.setTitleAndSubtitle(presenter.canvasContext.name.orEmpty(), (presenter.canvasContext as? Course)?.term?.name.orEmpty()) setupToolbar() + + // Force status bar to course color after all other initialization + requireActivity().window?.let { window -> + EdgeToEdgeHelper.setStatusBarColor(window, presenter.canvasContext.color) + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = false + } + if (!presenter.isEmpty) { checkIfEmpty() } @@ -197,12 +214,58 @@ class CourseBrowserFragment : BaseSyncFragment< overlayToolbar } + appBarLayout.setBackgroundColor(presenter.canvasContext.color) + + // Handle insets based on color overlay setting + // Skip top insets when masquerading - MasqueradeUI handles it + if (overlayToolbar.isVisible) { + // Color overlay enabled: apply padding to AppBarLayout + ViewCompat.setOnApplyWindowInsetsListener(appBarLayout) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val topInset = if (ApiPrefs.isMasquerading) 0 else systemBars.top + view.setPadding(view.paddingLeft, topInset, view.paddingRight, view.paddingBottom) + insets + } + ViewCompat.requestApplyInsets(appBarLayout) + } else { + // Color overlay disabled: Update toolbar height and add padding + val actionBarSize = resources.getDimensionPixelSize(androidx.appcompat.R.dimen.abc_action_bar_default_height_material) + ViewCompat.setOnApplyWindowInsetsListener(noOverlayToolbar) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val topInset = if (ApiPrefs.isMasquerading) 0 else systemBars.top + + // Update toolbar's actual height to accommodate status bar (use fixed base height) + val layoutParams = view.layoutParams + if (layoutParams != null) { + layoutParams.height = actionBarSize + topInset + view.layoutParams = layoutParams + } + + // Add padding to push content down + view.setPadding(view.paddingLeft, topInset, view.paddingRight, view.paddingBottom) + insets + } + ViewCompat.requestApplyInsets(noOverlayToolbar) + + // Make AppBarLayout extend behind status bar + ViewCompat.setOnApplyWindowInsetsListener(appBarLayout) { view, insets -> + insets // Don't add padding, just extend background + } + } + toolbar.setupBackButton(this@CourseBrowserFragment) toolbar.setupMenu(R.menu.menu_course_browser, menuItemCallback) ViewStyler.colorToolbarIconsAndText(requireActivity(), toolbar, requireContext().getColor(R.color.textLightest)) - ViewStyler.setStatusBarDark(requireActivity(), presenter.canvasContext.color) + + // Set status bar to course color with light icons (same as collapsed state) + EdgeToEdgeHelper.setStatusBarColor(requireActivity().window, presenter.canvasContext.color) + requireActivity().window?.let { window -> + val controller = ViewCompat.getWindowInsetsController(window.decorView) + controller?.isAppearanceLightStatusBars = false // White icons on colored background + } collapsingToolbarLayout.setContentScrimColor(presenter.canvasContext.color) + swipeRefreshLayout.applyBottomSystemBarInsets() // Hide image placeholder if color overlay is disabled and there is no valid image val hasImage = (presenter.canvasContext as? Course)?.imageUrl?.isValid() == true diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseSettingsFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseSettingsFragment.kt index 8c9b450ad6..d001706794 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseSettingsFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/CourseSettingsFragment.kt @@ -31,6 +31,8 @@ import com.instructure.pandautils.fragments.BasePresenterFragment import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyDisplayCutoutInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.onClickWithRequireNetwork import com.instructure.pandautils.utils.setCourseImage @@ -97,9 +99,13 @@ class CourseSettingsFragment : BasePresenterFragment< editCourseHomepage.root.onClickWithRequireNetwork { presenter.editCourseHomePageClicked(course) } + + // Apply display cutout insets to root view to prevent content from extending behind camera cutout + root.applyDisplayCutoutInsets() } private fun setupToolbar() = with(binding) { + toolbar.applyTopSystemBarInsets() toolbar.setupBackButton(this@CourseSettingsFragment) toolbar.title = getString(R.string.course_settings) ViewStyler.themeToolbarLight(requireActivity(), toolbar) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/CreateOrEditPageDetailsFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/CreateOrEditPageDetailsFragment.kt index 237b55bfde..5b604e6cac 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/CreateOrEditPageDetailsFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/CreateOrEditPageDetailsFragment.kt @@ -27,6 +27,9 @@ import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Page @@ -53,6 +56,7 @@ import com.instructure.pandautils.utils.RequestCodes import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.applyTheme +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.handleLTIPlaceHolders import com.instructure.pandautils.utils.hideKeyboard import com.instructure.pandautils.utils.onClickWithRequireNetwork @@ -97,7 +101,15 @@ class CreateOrEditPageDetailsFragment : BasePresenterFragment< override val skipCheck = false override fun onRefreshFinished() {} override fun onRefreshStarted() {} - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.toolbar.applyTopSystemBarInsets() + + ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } + } override fun onPresenterPrepared(presenter: CreateOrEditPagePresenter) {} override val bindingInflater: (layoutInflater: LayoutInflater) -> FragmentCreateOrEditPageBinding = FragmentCreateOrEditPageBinding::inflate diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/DashboardFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/DashboardFragment.kt index 93f0a6e499..1ebdb71530 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/DashboardFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/DashboardFragment.kt @@ -23,6 +23,9 @@ import android.view.MenuItem import android.view.MotionEvent import android.view.MotionEvent.ACTION_CANCEL import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.GridLayoutManager @@ -43,6 +46,7 @@ import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.Utils import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.fadeAnimationWithAction import com.instructure.pandautils.utils.getDrawableCompat import com.instructure.pandautils.utils.requestAccessibilityFocus @@ -160,6 +164,16 @@ class DashboardFragment : BaseSyncFragment + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = padding + systemBars.bottom) + insets + } + if (courseRecyclerView.isAttachedToWindow) { + ViewCompat.requestApplyInsets(courseRecyclerView) + } + emptyCoursesView.onClickAddCourses { routeEditDashboard() } setupHeader() @@ -182,6 +196,7 @@ class DashboardFragment : BaseSyncFragment(R.id.toolbar)?.applyTopSystemBarInsets() + + val recyclerView = view.findViewById(R.id.recyclerView) + ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } + } override fun onResume() { super.onResume() diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditAssignmentDetailsFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditAssignmentDetailsFragment.kt index 5be1cbca48..369bff8f75 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditAssignmentDetailsFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditAssignmentDetailsFragment.kt @@ -37,13 +37,17 @@ import com.instructure.canvasapi2.managers.AssignmentManager import com.instructure.canvasapi2.managers.GroupCategoriesManager import com.instructure.canvasapi2.managers.SectionManager import com.instructure.canvasapi2.managers.UserManager -import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Assignment.Companion.GPA_SCALE_TYPE import com.instructure.canvasapi2.models.Assignment.Companion.LETTER_GRADE_TYPE import com.instructure.canvasapi2.models.Assignment.Companion.NOT_GRADED_TYPE import com.instructure.canvasapi2.models.Assignment.Companion.PASS_FAIL_TYPE import com.instructure.canvasapi2.models.Assignment.Companion.PERCENT_TYPE import com.instructure.canvasapi2.models.Assignment.Companion.POINTS_TYPE +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Group +import com.instructure.canvasapi2.models.Section +import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.models.postmodels.AssignmentPostBody import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.NumberHelper @@ -61,7 +65,26 @@ import com.instructure.pandautils.dialogs.DatePickerDialogFragment import com.instructure.pandautils.dialogs.TimePickerDialogFragment import com.instructure.pandautils.discussions.DiscussionUtils import com.instructure.pandautils.fragments.BaseFragment -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.BooleanArg +import com.instructure.pandautils.utils.MediaUploadUtils +import com.instructure.pandautils.utils.ParcelableArg +import com.instructure.pandautils.utils.Placeholder +import com.instructure.pandautils.utils.RequestCodes +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyDisplayCutoutInsets +import com.instructure.pandautils.utils.applyTheme +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.children +import com.instructure.pandautils.utils.descendants +import com.instructure.pandautils.utils.handleLTIPlaceHolders +import com.instructure.pandautils.utils.hideKeyboard +import com.instructure.pandautils.utils.onTextChanged +import com.instructure.pandautils.utils.setGone +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.toast +import com.instructure.pandautils.utils.withArgs import com.instructure.pandautils.views.CanvasWebView import com.instructure.teacher.R import com.instructure.teacher.databinding.FragmentEditAssignmentDetailsBinding @@ -71,7 +94,12 @@ import com.instructure.teacher.events.AssignmentUpdatedEvent import com.instructure.teacher.events.post import com.instructure.teacher.models.DueDateGroup import com.instructure.teacher.router.RouteMatcher -import com.instructure.teacher.utils.* +import com.instructure.teacher.utils.EditDateGroups +import com.instructure.teacher.utils.getColorCompat +import com.instructure.teacher.utils.groupedDueDates +import com.instructure.teacher.utils.setGroupedDueDates +import com.instructure.teacher.utils.setupCloseButton +import com.instructure.teacher.utils.setupMenu import com.instructure.teacher.view.AssignmentOverrideView import kotlinx.coroutines.Job import org.greenrobot.eventbus.EventBus @@ -79,7 +107,7 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import java.text.DecimalFormat import java.text.ParseException -import java.util.* +import java.util.Date @PageView(url = "{canvasContext}/assignments/{assignmentId}") @ScreenView(SCREEN_VIEW_EDIT_ASSIGNMENT_DETAILS) @@ -166,6 +194,12 @@ class EditAssignmentDetailsFragment : BaseFragment() { setupViews() setupToolbar() + setupWindowInsets() + } + + private fun setupWindowInsets() = with(binding) { + root.applyDisplayCutoutInsets() + scrollView.applyBottomSystemBarInsets() } override fun onStart() { @@ -192,6 +226,7 @@ class EditAssignmentDetailsFragment : BaseFragment() { } private fun setupToolbar() = with(binding) { + toolbar.applyTopSystemBarInsets() toolbar.setupCloseButton(this@EditAssignmentDetailsFragment) toolbar.title = getString(R.string.edit_assignment) toolbar.setupMenu(R.menu.menu_save_generic) { saveAssignment() } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditFileFolderFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditFileFolderFragment.kt index cc192db583..72c701fcfa 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditFileFolderFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditFileFolderFragment.kt @@ -25,6 +25,9 @@ import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.AdapterView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import android.widget.ArrayAdapter import android.widget.EditText import android.widget.TextView @@ -54,6 +57,7 @@ import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ParcelableArrayListArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.descendants import com.instructure.pandautils.utils.postSticky import com.instructure.pandautils.utils.setGone @@ -145,6 +149,19 @@ class EditFileFolderFragment : BasePresenterFragment< showUsageRights(presenter.usageRightsEnabled) setupToolbar() setupViews() + setupWindowInsets() + } + + private fun setupWindowInsets() = with(binding) { + toolbar.applyTopSystemBarInsets() + ViewCompat.setOnApplyWindowInsetsListener(editFileFolderContentLayout) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = systemBars.bottom) + insets + } + if (editFileFolderScrollView.isAttachedToWindow) { + ViewCompat.requestApplyInsets(editFileFolderScrollView) + } } override fun getPresenterFactory() = EditFilePresenterFactory(currentFileOrFolder, usageRightsEnabled, licenseList, courseId) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditQuizDetailsFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditQuizDetailsFragment.kt index 28ddd801b7..2023f8004c 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditQuizDetailsFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/EditQuizDetailsFragment.kt @@ -56,7 +56,10 @@ import com.instructure.pandautils.utils.Placeholder import com.instructure.pandautils.utils.RequestCodes import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyDisplayCutoutInsets import com.instructure.pandautils.utils.applyTheme +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.children import com.instructure.pandautils.utils.descendants import com.instructure.pandautils.utils.handleLTIPlaceHolders @@ -152,6 +155,13 @@ class EditQuizDetailsFragment : BasePresenterFragment< // Hide Keyboard requireActivity().window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) setupToolbar() + setupWindowInsets() + } + + private fun setupWindowInsets() = with(binding) { + root.applyDisplayCutoutInsets() + toolbar.applyTopSystemBarInsets() + scrollView.applyBottomSystemBarInsets() } override fun populateQuizDetails() { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/FeatureFlagsFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/FeatureFlagsFragment.kt index ac077215c9..4c45dea40e 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/FeatureFlagsFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/FeatureFlagsFragment.kt @@ -20,12 +20,16 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.instructure.pandautils.base.BaseCanvasFragment import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.utils.FeatureFlagPref import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.isTablet import com.instructure.pandautils.utils.setupAsBackButton import com.instructure.teacher.R @@ -45,6 +49,14 @@ class FeatureFlagsFragment : BaseCanvasFragment() { super.onViewCreated(view, savedInstanceState) setupToolbar() binding.recyclerView.adapter = FeatureFlagAdapter() + + binding.toolbar.applyTopSystemBarInsets() + + ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } } private fun setupToolbar() { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/FileListFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/FileListFragment.kt index 2ecbcaf3df..b6c560a122 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/FileListFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/FileListFragment.kt @@ -50,6 +50,10 @@ import com.instructure.pandautils.utils.FileUploadEvent import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyBottomSystemBarMargin +import com.instructure.pandautils.utils.applyDisplayCutoutInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.getDrawableCompat import com.instructure.pandautils.utils.isCourse @@ -222,6 +226,7 @@ class FileListFragment : BaseSyncFragment< setupToolbar() setupViews() + setupWindowInsets() } override fun createAdapter(): FileListAdapter { @@ -308,10 +313,12 @@ class FileListFragment : BaseSyncFragment< } private fun setupViews() = with(binding) { + swipeRefreshLayout.applyBottomSystemBarInsets() + ViewStyler.themeFAB(addFab) ViewStyler.themeFAB(addFileFab) ViewStyler.themeFAB(addFolderFab) - + addFab.applyBottomSystemBarMargin() addFab.setOnClickListener { animateFabs() } addFileFab.setOnClickListener { animateFabs() @@ -345,6 +352,10 @@ class FileListFragment : BaseSyncFragment< }) } + private fun setupWindowInsets() = with(binding) { + fileListPage.applyDisplayCutoutInsets() + } + override fun workInfoLiveDataCallback(uuid: UUID?, workInfoLiveData: LiveData) { workInfoLiveData.observe(viewLifecycleOwner) { if (it?.state == WorkInfo.State.SUCCEEDED) { @@ -354,6 +365,7 @@ class FileListFragment : BaseSyncFragment< } private fun setupToolbar() = with(binding) { + fileListToolbar.applyTopSystemBarInsets() fileListToolbar.setupBackButton(this@FileListFragment) fileListToolbar.subtitle = presenter.mCanvasContext.name diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/InternalWebViewFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/InternalWebViewFragment.kt index fe56237bba..cfc709fd96 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/InternalWebViewFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/InternalWebViewFragment.kt @@ -22,6 +22,9 @@ import android.os.Bundle import android.view.View import android.webkit.WebView import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.instructure.canvasapi2.managers.OAuthManager import com.instructure.canvasapi2.models.AuthenticatedSession import com.instructure.canvasapi2.models.CanvasContext @@ -40,6 +43,8 @@ import com.instructure.pandautils.utils.BooleanArg import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.applyBottomSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.enableAlgorithmicDarkening import com.instructure.pandautils.utils.setGone @@ -102,6 +107,12 @@ open class InternalWebViewFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) toolbar = view.findViewById(R.id.toolbar) + + toolbar?.applyTopSystemBarInsets() + + if (!isInModulesPager) { + binding.canvasWebViewContainer.applyBottomSystemBarInsets() + } } override fun onActivityCreated(savedInstanceState: Bundle?) = with(binding) { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/NotATeacherFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/NotATeacherFragment.kt index 80178a5f0b..85ee021180 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/NotATeacherFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/NotATeacherFragment.kt @@ -19,8 +19,6 @@ package com.instructure.teacher.fragments import android.content.Intent import android.net.Uri import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.view.View import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.pandautils.analytics.SCREEN_VIEW_NOT_A_TEACHER @@ -28,6 +26,7 @@ import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.features.reminder.AlarmScheduler import com.instructure.pandautils.fragments.BaseFragment +import com.instructure.pandautils.utils.ViewStyler import com.instructure.teacher.R import com.instructure.teacher.databinding.FragmentNotATeacherBinding import com.instructure.teacher.tasks.TeacherLogoutTask @@ -53,6 +52,9 @@ class NotATeacherFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(binding) { super.onViewCreated(view, savedInstanceState) + if (activity != null) { + ViewStyler.themeStatusBar(requireActivity()) + } parentLink.setOnClickListener { openApp(PARENT_ID) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/PageDetailsFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/PageDetailsFragment.kt index e6c9dd05e2..81765762c2 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/PageDetailsFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/PageDetailsFragment.kt @@ -39,10 +39,13 @@ import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.features.lti.LtiLaunchFragment import com.instructure.pandautils.fragments.BasePresenterFragment import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.BooleanArg import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.PermissionUtils import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.getModuleItemId import com.instructure.pandautils.utils.isTablet @@ -85,6 +88,7 @@ class PageDetailsFragment : BasePresenterFragment< private var canvasContext: CanvasContext by ParcelableArg(default = Course()) private var page: Page by ParcelableArg(Page(), PAGE) private var pageId: String by StringArg(key = PAGE_ID) + private var isInModulesPager: Boolean by BooleanArg(key = IS_IN_MODULES_PAGER, default = false) private var downloadUrl: String? = null var downloadFileName: String? = null @@ -138,6 +142,9 @@ class PageDetailsFragment : BasePresenterFragment< presenter.getPage(page.url ?: "", canvasContext, true) } setupToolbar() + if (!isInModulesPager) { + canvasWebViewWraper.applyBottomSystemBarInsets() + } canvasWebViewWraper.webView.canvasWebViewClientCallback = object : CanvasWebView.CanvasWebViewClientCallback { override fun openMediaFromWebView(mime: String, url: String, filename: String) { @@ -222,6 +229,7 @@ class PageDetailsFragment : BasePresenterFragment< } private fun setupToolbar() = with(binding) { + toolbar.applyTopSystemBarInsets() toolbar.setupMenu(R.menu.menu_page_details) { openEditPage(page) } toolbar.setupBackButtonWithExpandCollapseAndBack(this@PageDetailsFragment) { @@ -268,13 +276,18 @@ class PageDetailsFragment : BasePresenterFragment< companion object { const val PAGE = "pageDetailsPage" - const val PAGE_ID = "pageDetailsId" + private const val IS_IN_MODULES_PAGER = "isInModulesPager" - fun makeBundle(page: Page): Bundle = Bundle().apply { putParcelable(PAGE, page) } - - fun makeBundle(pageId: String): Bundle = Bundle().apply { putString(PAGE_ID, pageId) } + fun makeBundle(page: Page, isInModulesPager: Boolean = false): Bundle = Bundle().apply { + putParcelable(PAGE, page) + putBoolean(IS_IN_MODULES_PAGER, isInModulesPager) + } + fun makeBundle(pageId: String, isInModulesPager: Boolean = false): Bundle = Bundle().apply { + putString(PAGE_ID, pageId) + putBoolean(IS_IN_MODULES_PAGER, isInModulesPager) + } fun newInstance(canvasContext: CanvasContext, args: Bundle) = PageDetailsFragment().withArgs(args).apply { this.canvasContext = canvasContext diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/PageListFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/PageListFragment.kt index 0dddfc2acd..9d29974bfa 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/PageListFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/PageListFragment.kt @@ -36,6 +36,10 @@ import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.addSearch +import com.instructure.pandautils.utils.applyImeAndSystemBarInsets +import com.instructure.pandautils.utils.applyBottomSystemBarMargin +import com.instructure.pandautils.utils.applyDisplayCutoutInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.closeSearch import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.getDrawableCompat @@ -102,6 +106,9 @@ class PageListFragment : BaseSyncFragment(Const.CANVAS_CONTEXT) + peopleListToolbar.applyTopSystemBarInsets() + swipeRefreshLayoutContainerBinding.swipeRefreshLayout.applyImeAndSystemBarInsets() + swipeRefreshLayoutContainerBinding.recyclerView.clipToPadding = false peopleListToolbar.setTitle(R.string.tab_people) peopleListToolbar.subtitle = canvasContext!!.name if (peopleListToolbar.menu.size() == 0) peopleListToolbar.inflateMenu(R.menu.menu_people_list) @@ -199,6 +205,9 @@ class PeopleListFragment : BaseSyncFragment + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(bottom = systemBars.bottom) + insets + } + } + } + override fun onRefreshFinished() = Unit override fun onRefreshStarted() { @@ -536,10 +560,17 @@ class QuizDetailsFragment : BasePresenterFragment< companion object { @JvmStatic val QUIZ_ID = "quiz_details_quiz_id" @JvmStatic val QUIZ = "quiz_details_quiz" + private const val IS_IN_MODULES_PAGER = "isInModulesPager" - fun makeBundle(quizId: Long): Bundle = Bundle().apply { putLong(QuizDetailsFragment.QUIZ_ID, quizId) } + fun makeBundle(quizId: Long, isInModulesPager: Boolean = false): Bundle = Bundle().apply { + putLong(QuizDetailsFragment.QUIZ_ID, quizId) + putBoolean(IS_IN_MODULES_PAGER, isInModulesPager) + } - fun makeBundle(quiz: Quiz): Bundle = Bundle().apply { putParcelable(QuizDetailsFragment.QUIZ, quiz) } + fun makeBundle(quiz: Quiz, isInModulesPager: Boolean = false): Bundle = Bundle().apply { + putParcelable(QuizDetailsFragment.QUIZ, quiz) + putBoolean(IS_IN_MODULES_PAGER, isInModulesPager) + } fun newInstance(course: Course, args: Bundle) = QuizDetailsFragment().withArgs(args).apply { this.course = course } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/QuizListFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/QuizListFragment.kt index 6aad4ebde5..34f093b180 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/QuizListFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/QuizListFragment.kt @@ -34,8 +34,9 @@ import com.instructure.pandautils.fragments.BaseExpandableSyncFragment import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.addSearch -import com.instructure.pandautils.utils.closeSearch -import com.instructure.pandautils.utils.getDrawableCompat +import com.instructure.pandautils.utils.applyImeAndSystemBarInsets +import com.instructure.pandautils.utils.applyDisplayCutoutInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.closeSearch import com.instructure.pandautils.utils.color import com.instructure.pandautils.utils.getDrawableCompat @@ -91,6 +92,9 @@ class QuizListFragment : BaseExpandableSyncFragment< emptyViewResId = R.id.emptyPandaView, emptyViewText = getString(R.string.noQuizzesSubtext) ) + + // Apply display cutout insets to root view to prevent content from extending behind camera cutout + rootView.applyDisplayCutoutInsets() } override fun onCreateView(view: View) { @@ -165,6 +169,9 @@ class QuizListFragment : BaseExpandableSyncFragment< override fun perPageCount() = ApiPrefs.perPageCount private fun setupToolbar() = with(binding) { + quizListToolbar.applyTopSystemBarInsets() + swipeRefreshLayout.applyImeAndSystemBarInsets() + quizRecyclerView.clipToPadding = false quizListToolbar.title = getString(R.string.tab_quizzes) quizListToolbar.subtitle = canvasContext.name quizListToolbar.setupBackButton(this@QuizListFragment) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/SpeedGraderCommentsFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/SpeedGraderCommentsFragment.kt index 563dddce6c..5dcfe6f7b3 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/SpeedGraderCommentsFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/SpeedGraderCommentsFragment.kt @@ -58,6 +58,7 @@ import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.ParcelableArrayListArg import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyImeAndSystemBarInsets import com.instructure.pandautils.utils.onClick import com.instructure.pandautils.utils.onClickWithRequireNetwork import com.instructure.pandautils.utils.onTextChanged @@ -159,6 +160,12 @@ class SpeedGraderCommentsFragment : BaseListFragment + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = systemBars.bottom) + insets + } + if (speedGraderFilesRecyclerView.isAttachedToWindow) { + ViewCompat.requestApplyInsets(speedGraderFilesRecyclerView) + } } override fun onReadySetGo(presenter: SpeedGraderFilesPresenter) { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/SpeedGraderGradeFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/SpeedGraderGradeFragment.kt index bc43178cfa..d084d65758 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/SpeedGraderGradeFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/SpeedGraderGradeFragment.kt @@ -17,6 +17,9 @@ package com.instructure.teacher.fragments import android.view.LayoutInflater import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.instructure.canvasapi2.models.Assignee import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course @@ -70,6 +73,18 @@ class SpeedGraderGradeFragment : BasePresenterFragment< override fun onPresenterPrepared(presenter: SpeedGraderGradePresenter) { setupViews() + setupWindowInsets() + } + + private fun setupWindowInsets() = with(binding) { + ViewCompat.setOnApplyWindowInsetsListener(speedGraderGradeContentLayout) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = systemBars.bottom) + insets + } + if (speedGraderGradeScrollView.isAttachedToWindow) { + ViewCompat.requestApplyInsets(speedGraderGradeScrollView) + } } override fun onStart() { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ToDoFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ToDoFragment.kt index f3a9802766..c92a00ddeb 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ToDoFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ToDoFragment.kt @@ -52,6 +52,8 @@ import com.instructure.teacher.router.RouteMatcher import com.instructure.teacher.utils.RecyclerViewUtils import com.instructure.teacher.utils.setupBackButtonAsBackPressedOnly import com.instructure.teacher.viewinterface.ToDoView +import com.instructure.pandautils.utils.applyTopSystemBarInsets +import com.instructure.pandautils.utils.applyBottomSystemBarInsets import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -110,6 +112,7 @@ class ToDoFragment : BaseSyncFragment, p3: Boolean - ): Boolean = with(binding) { - photoView.setGone() - progressBar.setGone() - errorContainer.setVisible() - ViewStyler.themeButton(openExternallyButton) - openExternallyButton.onClick { uri?.viewExternally(requireContext(), contentType) } + ): Boolean { + if (view == null) return false + binding.photoView.setGone() + binding.progressBar.setGone() + binding.errorContainer.setVisible() + ViewStyler.themeButton(binding.openExternallyButton) + binding.openExternallyButton.onClick { uri?.viewExternally(requireContext(), contentType) } return false } @@ -195,6 +202,7 @@ class ViewImageFragment : BaseCanvasFragment(), ShareableFile { dataSource: DataSource, p4: Boolean ): Boolean { + if (view == null) return false binding.progressBar.setGone() // Try to set the background color using palette if we can diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewMediaFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewMediaFragment.kt index 547ed991c2..750dabe76a 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewMediaFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewMediaFragment.kt @@ -53,6 +53,7 @@ import com.instructure.pandautils.utils.ParcelableArg import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.Utils import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.onClick import com.instructure.pandautils.utils.setGone import com.instructure.pandautils.utils.setVisible @@ -87,6 +88,13 @@ class ViewMediaFragment : BaseCanvasFragment(), ShareableFile { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) binding.speedGraderMediaPlayerView.findViewById(R.id.toolbar).setGone() + setupWindowInsets() + } + + private fun setupWindowInsets() = with(binding) { + if (isInModulesPager) { + toolbar.applyTopSystemBarInsets() + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -260,9 +268,9 @@ class ViewMediaFragment : BaseCanvasFragment(), ShareableFile { thumbnailUrl: String?, contentType: String, displayName: String?, - isInModulesPager: Boolean = false, toolbarColor: Int = 0, - editableFile: EditableFile? = null + editableFile: EditableFile? = null, + isInModulesPager: Boolean = false ) = ViewMediaFragment().apply { this.uri = uri this.thumbnailUrl = thumbnailUrl diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewPdfFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewPdfFragment.kt index 6c0ffe8b24..48918d5553 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewPdfFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewPdfFragment.kt @@ -23,12 +23,16 @@ import android.view.View import android.view.ViewGroup import com.instructure.interactions.MasterDetailInteractions import com.instructure.interactions.router.Route +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import com.instructure.pandautils.analytics.SCREEN_VIEW_VIEW_PDF import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.models.EditableFile import com.instructure.pandautils.utils.* import com.instructure.pandautils.utils.Utils.copyToClipboard +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.teacher.R import com.instructure.teacher.databinding.FragmentViewPdfBinding import com.instructure.teacher.factory.ViewPdfFragmentPresenterFactory @@ -61,6 +65,12 @@ class ViewPdfFragment : PresenterFragment @@ -151,6 +162,12 @@ class ViewPdfFragment : PresenterFragment @@ -108,17 +108,34 @@ - + android:background="@color/backgroundLightestElevated"> + + + + + + diff --git a/apps/teacher/src/main/res/layout/fragment_annotation_comment_list.xml b/apps/teacher/src/main/res/layout/fragment_annotation_comment_list.xml index 5d396f29b6..310c463fc5 100644 --- a/apps/teacher/src/main/res/layout/fragment_annotation_comment_list.xml +++ b/apps/teacher/src/main/res/layout/fragment_annotation_comment_list.xml @@ -24,6 +24,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize" android:elevation="2dp" tools:ignore="UnusedAttribute"/> @@ -39,7 +40,8 @@ android:animateLayoutChanges="true" android:id="@+id/commentInputContainer" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="0dp" + android:minHeight="56dp" android:layout_alignParentBottom="true" android:background="@color/backgroundLightest"> diff --git a/apps/teacher/src/main/res/layout/fragment_assignee_list.xml b/apps/teacher/src/main/res/layout/fragment_assignee_list.xml index a529d0b4b1..76b4ed87b5 100644 --- a/apps/teacher/src/main/res/layout/fragment_assignee_list.xml +++ b/apps/teacher/src/main/res/layout/fragment_assignee_list.xml @@ -27,6 +27,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize" app:title="@string/page_title_add_assignees"/> diff --git a/apps/teacher/src/main/res/layout/fragment_course_settings.xml b/apps/teacher/src/main/res/layout/fragment_course_settings.xml index afb8408621..cb0448ba42 100644 --- a/apps/teacher/src/main/res/layout/fragment_course_settings.xml +++ b/apps/teacher/src/main/res/layout/fragment_course_settings.xml @@ -24,7 +24,8 @@ + android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize"/> diff --git a/apps/teacher/src/main/res/layout/fragment_dashboard.xml b/apps/teacher/src/main/res/layout/fragment_dashboard.xml index d50e04d630..0c4c6363ba 100644 --- a/apps/teacher/src/main/res/layout/fragment_dashboard.xml +++ b/apps/teacher/src/main/res/layout/fragment_dashboard.xml @@ -23,7 +23,8 @@ diff --git a/apps/teacher/src/main/res/layout/fragment_edit_filefolder.xml b/apps/teacher/src/main/res/layout/fragment_edit_filefolder.xml index 3bf1f639b2..8838581cfe 100644 --- a/apps/teacher/src/main/res/layout/fragment_edit_filefolder.xml +++ b/apps/teacher/src/main/res/layout/fragment_edit_filefolder.xml @@ -26,15 +26,18 @@ + android:layout_height="match_parent" + android:clipToPadding="false"/> - + android:clipToPadding="false"> + + + + diff --git a/apps/teacher/src/main/res/layout/fragment_quiz_details.xml b/apps/teacher/src/main/res/layout/fragment_quiz_details.xml index 94a325b9f5..6c174dfde7 100644 --- a/apps/teacher/src/main/res/layout/fragment_quiz_details.xml +++ b/apps/teacher/src/main/res/layout/fragment_quiz_details.xml @@ -26,6 +26,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize" android:elevation="6dp" tools:background="#00bcd5" tools:ignore="UnusedAttribute"/> diff --git a/apps/teacher/src/main/res/layout/fragment_quiz_list.xml b/apps/teacher/src/main/res/layout/fragment_quiz_list.xml index 49b473c129..0fd9d1c82f 100644 --- a/apps/teacher/src/main/res/layout/fragment_quiz_list.xml +++ b/apps/teacher/src/main/res/layout/fragment_quiz_list.xml @@ -25,7 +25,8 @@ diff --git a/apps/teacher/src/main/res/layout/fragment_speedgrader_comments.xml b/apps/teacher/src/main/res/layout/fragment_speedgrader_comments.xml index 506ba23a0d..ed51855282 100644 --- a/apps/teacher/src/main/res/layout/fragment_speedgrader_comments.xml +++ b/apps/teacher/src/main/res/layout/fragment_speedgrader_comments.xml @@ -32,6 +32,7 @@ android:id="@+id/speedGraderCommentsRecyclerView" android:layout_width="match_parent" android:layout_height="wrap_content" + android:clipToPadding="false" android:cacheColorHint="@android:color/transparent" app:layout_behavior="@string/appbar_scrolling_view_behavior"/> diff --git a/apps/teacher/src/main/res/layout/fragment_speedgrader_files.xml b/apps/teacher/src/main/res/layout/fragment_speedgrader_files.xml index a327865269..559a0fe4a4 100644 --- a/apps/teacher/src/main/res/layout/fragment_speedgrader_files.xml +++ b/apps/teacher/src/main/res/layout/fragment_speedgrader_files.xml @@ -30,6 +30,7 @@ android:id="@+id/speedGraderFilesRecyclerView" android:layout_width="match_parent" android:layout_height="wrap_content" + android:clipToPadding="false" android:cacheColorHint="@android:color/transparent" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> diff --git a/apps/teacher/src/main/res/layout/fragment_speedgrader_grade.xml b/apps/teacher/src/main/res/layout/fragment_speedgrader_grade.xml index 7be8f35b5d..eb5c676ef4 100644 --- a/apps/teacher/src/main/res/layout/fragment_speedgrader_grade.xml +++ b/apps/teacher/src/main/res/layout/fragment_speedgrader_grade.xml @@ -16,11 +16,13 @@ diff --git a/apps/teacher/src/main/res/layout/fragment_student_context.xml b/apps/teacher/src/main/res/layout/fragment_student_context.xml index 09cb655e9f..a9d9c253ad 100644 --- a/apps/teacher/src/main/res/layout/fragment_student_context.xml +++ b/apps/teacher/src/main/res/layout/fragment_student_context.xml @@ -32,7 +32,8 @@ diff --git a/apps/teacher/src/main/res/layout/fragment_view_image.xml b/apps/teacher/src/main/res/layout/fragment_view_image.xml index 1710c0e048..6843188c43 100644 --- a/apps/teacher/src/main/res/layout/fragment_view_image.xml +++ b/apps/teacher/src/main/res/layout/fragment_view_image.xml @@ -25,6 +25,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize" android:elevation="2dp" tools:ignore="UnusedAttribute"/> diff --git a/apps/teacher/src/main/res/layout/fragment_view_pdf.xml b/apps/teacher/src/main/res/layout/fragment_view_pdf.xml index f6beba807f..06ad57ef3b 100644 --- a/apps/teacher/src/main/res/layout/fragment_view_pdf.xml +++ b/apps/teacher/src/main/res/layout/fragment_view_pdf.xml @@ -24,6 +24,7 @@ android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" + android:minHeight="?android:attr/actionBarSize" android:elevation="2dp" tools:ignore="UnusedAttribute"/> diff --git a/apps/teacher/src/main/res/layout/speed_grader_comment_input_view.xml b/apps/teacher/src/main/res/layout/speed_grader_comment_input_view.xml index 8e69f6aa36..bc2a795b2f 100644 --- a/apps/teacher/src/main/res/layout/speed_grader_comment_input_view.xml +++ b/apps/teacher/src/main/res/layout/speed_grader_comment_input_view.xml @@ -18,6 +18,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> الحضور الصفحات قائمة مهام + المهمة شعار المدرسة البحث في Canvas Guides Canvas Guides @@ -890,6 +891,7 @@ لا يوجد شيء آخر يمكن فعله!\n طاب يومك. لا توجد مهام مرسلة لتقييمها لهذه المهمة. حدث خطأ أثناء محاولة عرض عنصر المهام هذا. + وقع خطأ أثناء محاولة عرض عنصر المهمة هذا. لا يمكن لتسجيل المصمم عرض هذا. الأقسام diff --git a/apps/teacher/src/main/res/values-b+da+DK+instk12/strings.xml b/apps/teacher/src/main/res/values-b+da+DK+instk12/strings.xml index 6b34f6c546..58532761c1 100644 --- a/apps/teacher/src/main/res/values-b+da+DK+instk12/strings.xml +++ b/apps/teacher/src/main/res/values-b+da+DK+instk12/strings.xml @@ -31,6 +31,7 @@ Tilstedeværelse Sider Opgaveliste + At gøre Skolelogo Søg i Canvas-vejledningerne Canvas-vejledningerne @@ -844,6 +845,7 @@ Ikke mere at gøre!\n Hav en god dag! Der er ingen afleveringer til bedømmelse for denne opgave. Der opstod en fejl ved forsøg på at vise dette element på opgavelisten. + Der opstod en fejl ved forsøg på at vise dette element på opgavelisten. Designer-tilmelding kan ikke se dette. Sektioner diff --git a/apps/teacher/src/main/res/values-b+en+AU+unimelb/strings.xml b/apps/teacher/src/main/res/values-b+en+AU+unimelb/strings.xml index 8764ce3941..07f1092930 100644 --- a/apps/teacher/src/main/res/values-b+en+AU+unimelb/strings.xml +++ b/apps/teacher/src/main/res/values-b+en+AU+unimelb/strings.xml @@ -31,6 +31,7 @@ Attendance Pages To Do + To-do School Logo Search the Canvas Guides Canvas Guides @@ -842,6 +843,7 @@ Nothing more to do!\n Enjoy your day. There are no submissions to grade for this assignment. An error occurred trying to view this To Do item. + An error occurred trying to view this To-do item. Designer enrolment cannot view this. Sections diff --git a/apps/teacher/src/main/res/values-b+en+GB+instukhe/strings.xml b/apps/teacher/src/main/res/values-b+en+GB+instukhe/strings.xml index 06fe0712e4..36ab392b68 100644 --- a/apps/teacher/src/main/res/values-b+en+GB+instukhe/strings.xml +++ b/apps/teacher/src/main/res/values-b+en+GB+instukhe/strings.xml @@ -31,6 +31,7 @@ Attendance Pages To do + To-do School Logo Search the Canvas Guides Canvas Guides @@ -842,6 +843,7 @@ Nothing more to do!\n Enjoy your day. There are no submissions to grade for this assignment. An error occurred trying to view this to-do item. + An error occurred trying to view this To-do item. Designer enrolment cannot view this. Sections diff --git a/apps/teacher/src/main/res/values-b+nb+NO+instk12/strings.xml b/apps/teacher/src/main/res/values-b+nb+NO+instk12/strings.xml index 813790e8ca..0ec73b8713 100644 --- a/apps/teacher/src/main/res/values-b+nb+NO+instk12/strings.xml +++ b/apps/teacher/src/main/res/values-b+nb+NO+instk12/strings.xml @@ -31,6 +31,7 @@ Oppmøte Sider Å gjøre + Gjøremål Skolens Logo Søk i Canvas-guidene Canvas-guider @@ -844,6 +845,7 @@ Ingen flere oppgaver!\n Ha en fin dag. Det finnes ingen innleveringer å gi vurderinger til for denne oppgaven. En feil oppsto under visning av dette gjøremålselementet. + En feil oppsto under visning av dette gjøremålselementet. Designer påmelding kan ikke vise dette. Seksjoner diff --git a/apps/teacher/src/main/res/values-b+sv+SE+instk12/strings.xml b/apps/teacher/src/main/res/values-b+sv+SE+instk12/strings.xml index 70bd3aa391..b116079b79 100644 --- a/apps/teacher/src/main/res/values-b+sv+SE+instk12/strings.xml +++ b/apps/teacher/src/main/res/values-b+sv+SE+instk12/strings.xml @@ -31,6 +31,7 @@ Närvaro Sidor Att göra + Att göra Skolans logotyp Sök i Canvas-guider Canvas-guider @@ -844,6 +845,7 @@ Inget mer att göra!\n Ha en bra dag. Det finns inga inlämningar att bedöma för denna uppgift. Ett fel uppstod när du försökte visa detta att göra-objekt. + Ett fel inträffade vid försök att visa det här att göra-objektet. Designerregistrering kan inte visa detta. Sektioner diff --git a/apps/teacher/src/main/res/values-b+zh+HK/strings.xml b/apps/teacher/src/main/res/values-b+zh+HK/strings.xml index ec68181f05..660ba68168 100644 --- a/apps/teacher/src/main/res/values-b+zh+HK/strings.xml +++ b/apps/teacher/src/main/res/values-b+zh+HK/strings.xml @@ -31,6 +31,7 @@ 考勤 頁面 待辦事項 + 待辦事項 學校圖標 搜尋 Canvas 指南 Canvas 指南 @@ -830,6 +831,7 @@ 沒有待辦事項!\n祝您愉快。 沒有針對該作業的提交項目可供評分。 嘗試檢視此待辦事項時發生錯誤。 + 嘗試檢視此待辦事項時發生錯誤。 設計師註冊無法檢視此項。 章節 diff --git a/apps/teacher/src/main/res/values-b+zh+Hans/strings.xml b/apps/teacher/src/main/res/values-b+zh+Hans/strings.xml index 934a615f29..b66b6b5697 100644 --- a/apps/teacher/src/main/res/values-b+zh+Hans/strings.xml +++ b/apps/teacher/src/main/res/values-b+zh+Hans/strings.xml @@ -31,6 +31,7 @@ 出勤 页面 待办事项 + 待办 学校徽标 搜索 Canvas 指南 Canvas 指南 @@ -830,6 +831,7 @@ 无需再执行其他操作!\n祝您度过愉快的一天。 此作业没有要评分的提交项。 尝试查看此待办事项时出错。 + 尝试查看此待办任务时出错。 设计者注册无法查看。 章节 diff --git a/apps/teacher/src/main/res/values-b+zh+Hant/strings.xml b/apps/teacher/src/main/res/values-b+zh+Hant/strings.xml index ec68181f05..660ba68168 100644 --- a/apps/teacher/src/main/res/values-b+zh+Hant/strings.xml +++ b/apps/teacher/src/main/res/values-b+zh+Hant/strings.xml @@ -31,6 +31,7 @@ 考勤 頁面 待辦事項 + 待辦事項 學校圖標 搜尋 Canvas 指南 Canvas 指南 @@ -830,6 +831,7 @@ 沒有待辦事項!\n祝您愉快。 沒有針對該作業的提交項目可供評分。 嘗試檢視此待辦事項時發生錯誤。 + 嘗試檢視此待辦事項時發生錯誤。 設計師註冊無法檢視此項。 章節 diff --git a/apps/teacher/src/main/res/values-ca/strings.xml b/apps/teacher/src/main/res/values-ca/strings.xml index 6724c41d5a..f9f4905244 100644 --- a/apps/teacher/src/main/res/values-ca/strings.xml +++ b/apps/teacher/src/main/res/values-ca/strings.xml @@ -31,6 +31,7 @@ Assistència Pàgines Tasques pendents + Tasques pendents Logotip de l\'escola Cerca les guies del Canvas Guies del Canvas @@ -538,7 +539,7 @@ Prova sobre pràctiques Prova amb nota Estudi amb nota - Estudi sense nota + Enquesta sense nota @string/practiceQuiz @@ -580,8 +581,8 @@ Il·limitat Prova sobre pràctiques Prova amb nota - Estudi qualificat - Estudi sense nota + Enquesta qualificada + Enquesta sense nota Sense límit de temps Immediatament No hi ha cap punt assignat @@ -844,6 +845,7 @@ Ja no s\'ha de fer res més.\n Que tingueu bon dia. No hi ha cap entrega que s\'hagi de qualificar per a aquesta activitat. S\'ha produït un error en provar de veure aquest element de tasques pendents. + S\'ha produït un error en provar de visualitzar aquest element de Tasques pendents. En la inscripció de dissenyadors no es pot veure això. Seccions diff --git a/apps/teacher/src/main/res/values-cy/strings.xml b/apps/teacher/src/main/res/values-cy/strings.xml index e52cc88323..4bf591bb04 100644 --- a/apps/teacher/src/main/res/values-cy/strings.xml +++ b/apps/teacher/src/main/res/values-cy/strings.xml @@ -31,6 +31,7 @@ Presenoldeb Tudalennau Tasgau i’w Gwneud + Tasg i’w Gwneud Logo’r Ysgol Chwilio drwy Ganllawiau Canvas Canllawiau Canvas @@ -842,6 +843,7 @@ Dim byd arall i\'w wneud!\n Mwynhewch eich diwrnod. Does dim cyflwyniadau i’w graddio ar gyfer yr aseiniad hwn. Gwall wrth geisio gweld yr eitem i’w gwneud. + Cafwyd gwall wrth geisio gweld yr eitem i’w gwneud. Does dim modd i ymrestriadau dylunydd weld hwn. Adrannau diff --git a/apps/teacher/src/main/res/values-da/strings.xml b/apps/teacher/src/main/res/values-da/strings.xml index d89f254ecb..4a7d6bba47 100644 --- a/apps/teacher/src/main/res/values-da/strings.xml +++ b/apps/teacher/src/main/res/values-da/strings.xml @@ -31,6 +31,7 @@ Tilstedeværelse Sider Opgaveliste + Element på opgaveliste Skolelogo Søg i Canvas-vejledningerne Canvas-vejledningerne @@ -842,6 +843,7 @@ Ikke mere at gøre!\n Hav en god dag. Der er ingen afleveringer til bedømmelse for denne opgave. Der opstod en fejl ved forsøg på at vise dette element på opgavelisten. + Der opstod en fejl ved forsøg på at vise dette element på opgavelisten. Designer-tilmelding kan ikke se dette. Sektioner diff --git a/apps/teacher/src/main/res/values-de/strings.xml b/apps/teacher/src/main/res/values-de/strings.xml index 20cc4cc6d9..78e81d40d8 100644 --- a/apps/teacher/src/main/res/values-de/strings.xml +++ b/apps/teacher/src/main/res/values-de/strings.xml @@ -31,6 +31,7 @@ Anwesenheit Seiten Zu erledigen + Aufgaben Logo der Schule Die Canvas-Anleitungen durchsuchen Canvas-Leitfäden @@ -353,7 +354,7 @@ Buchstaben-Note Student entschuldigen Gruppe entschuldigen - Fertigstellen + Vollständig Unvollständig Unbenotet Entschuldigt @@ -546,7 +547,7 @@ Nicht erledigt - Fertigstellen + Vollständig Ausstehende Überprüfung In Arbeit Nicht begonnen @@ -842,6 +843,7 @@ Nichts weiter zu tun.\n Genießen Sie Ihren Tag! Für diese Aufgabe sind keine Abgaben zu benoten. Fehler beim Anzeigen dieser Aufgabe. + Bei der Ansicht des To-Do-Elements ist ein Fehler aufgetreten. Designer-Anmeldung kann dies nicht anzeigen. Abschnitte diff --git a/apps/teacher/src/main/res/values-en-rAU/strings.xml b/apps/teacher/src/main/res/values-en-rAU/strings.xml index 62a4f98001..fdbda1892f 100644 --- a/apps/teacher/src/main/res/values-en-rAU/strings.xml +++ b/apps/teacher/src/main/res/values-en-rAU/strings.xml @@ -31,6 +31,7 @@ Attendance Pages To Do + To-do School Logo Search the Canvas Guides Canvas Guides @@ -842,6 +843,7 @@ Nothing more to do!\n Enjoy your day. There are no submissions to mark for this assignment. An error occurred trying to view this To Do item. + An error occurred trying to view this To-do item. Designer enrolment cannot view this. Sections diff --git a/apps/teacher/src/main/res/values-en-rCA/strings.xml b/apps/teacher/src/main/res/values-en-rCA/strings.xml index 132287df5b..92bca6edd3 100644 --- a/apps/teacher/src/main/res/values-en-rCA/strings.xml +++ b/apps/teacher/src/main/res/values-en-rCA/strings.xml @@ -31,6 +31,7 @@ Attendance Pages To Do + To-do School Logo Search the Canvas Guides Canvas Guides @@ -845,6 +846,7 @@ Nothing more to do!\n Enjoy your day. There are no submissions to grade for this assignment. An error occurred trying to view this To Do item. + An error occurred trying to view this To-do item. Designer enrollment cannot view this. Sections diff --git a/apps/teacher/src/main/res/values-en-rCY/strings.xml b/apps/teacher/src/main/res/values-en-rCY/strings.xml index 06fe0712e4..36ab392b68 100644 --- a/apps/teacher/src/main/res/values-en-rCY/strings.xml +++ b/apps/teacher/src/main/res/values-en-rCY/strings.xml @@ -31,6 +31,7 @@ Attendance Pages To do + To-do School Logo Search the Canvas Guides Canvas Guides @@ -842,6 +843,7 @@ Nothing more to do!\n Enjoy your day. There are no submissions to grade for this assignment. An error occurred trying to view this to-do item. + An error occurred trying to view this To-do item. Designer enrolment cannot view this. Sections diff --git a/apps/teacher/src/main/res/values-en-rGB/strings.xml b/apps/teacher/src/main/res/values-en-rGB/strings.xml index 1e2d03c067..eae0a2de22 100644 --- a/apps/teacher/src/main/res/values-en-rGB/strings.xml +++ b/apps/teacher/src/main/res/values-en-rGB/strings.xml @@ -31,6 +31,7 @@ Attendance Pages To do + To-do School Logo Search the Canvas Guides Canvas Guides @@ -842,6 +843,7 @@ Nothing more to do!\n Enjoy your day. There are no submissions to grade for this assignment. An error occurred trying to view this to-do item. + An error occurred trying to view this To-do item. Designer enrolment cannot view this. Sections diff --git a/apps/teacher/src/main/res/values-en/strings.xml b/apps/teacher/src/main/res/values-en/strings.xml index ae9fe72034..8e66b64ee2 100644 --- a/apps/teacher/src/main/res/values-en/strings.xml +++ b/apps/teacher/src/main/res/values-en/strings.xml @@ -31,6 +31,7 @@ Attendance Pages To Do + To-do School Logo Search the Canvas Guides Canvas Guides @@ -845,6 +846,7 @@ Nothing more to do!\n Enjoy your day. There are no submissions to grade for this assignment. An error occurred trying to view this To Do item. + An error occurred trying to view this To-do item. Designer enrollment cannot view this. Sections diff --git a/apps/teacher/src/main/res/values-es-rES/strings.xml b/apps/teacher/src/main/res/values-es-rES/strings.xml index 7980014f82..199350c3b8 100644 --- a/apps/teacher/src/main/res/values-es-rES/strings.xml +++ b/apps/teacher/src/main/res/values-es-rES/strings.xml @@ -31,6 +31,7 @@ Asistencia Páginas Tareas pendientes + Tareas pendientes Logo de la escuela Buscar en las guías de Canvas Guías de Canvas @@ -844,6 +845,7 @@ Nada más pendiente.\n Que tengas un buen día. No hay entregas para evaluar para esta actividad. Ha habido un error al intentar ver este ítem de tareas pendientes. + Se ha producido un error al intentar ver este ítem de tareas pendientes. La inscripción de diseñador no puede ver esto. Secciones diff --git a/apps/teacher/src/main/res/values-es/strings.xml b/apps/teacher/src/main/res/values-es/strings.xml index f75c1bb81b..cd422891dc 100644 --- a/apps/teacher/src/main/res/values-es/strings.xml +++ b/apps/teacher/src/main/res/values-es/strings.xml @@ -31,6 +31,7 @@ Asistencia Páginas Por hacer + Ítem Por hacer Logo de la escuela Busque en las guías de Canvas Guías de Canvas @@ -843,6 +844,7 @@ ¡Nada más que hacer!\n Disfrute su día. No hay entregas para calificar para esta tarea. Se produjo un error al intentar ver este item por hacer. + Se produjo un error al intentar ver este ítem Por hacer. La inscripción de diseñador no puede ver esto. Secciones diff --git a/apps/teacher/src/main/res/values-fi/strings.xml b/apps/teacher/src/main/res/values-fi/strings.xml index f3e32ddb8e..736cf07915 100644 --- a/apps/teacher/src/main/res/values-fi/strings.xml +++ b/apps/teacher/src/main/res/values-fi/strings.xml @@ -31,6 +31,7 @@ Osallistuminen Sivut Tehtävä + Tehtävälista Koulun logo Etsi Canvas-oppaita Canvas-oppaat @@ -842,6 +843,7 @@ Ei enempää tehtävänä!\n Nauti päivästäsi. Ei ole lähetyksiä tämän tehtävän arvosteluun. Tämän Tehtävä-kohteen näyttämisen yhteydessä ilmeni virhe. + Tämän Tehtävä-kohteen näyttämisen yhteydessä ilmeni virhe. Suunnittelijan ilmoittautumista ei voi tarkastella tätä. Osat diff --git a/apps/teacher/src/main/res/values-fr/strings.xml b/apps/teacher/src/main/res/values-fr/strings.xml index 9f776238be..792d696d3e 100644 --- a/apps/teacher/src/main/res/values-fr/strings.xml +++ b/apps/teacher/src/main/res/values-fr/strings.xml @@ -31,6 +31,7 @@ Présence Pages À faire + À Faire Logo de l’école Rechercher dans les guides de Canvas Guides de Canvas @@ -842,6 +843,7 @@ Rien d’autre à faire !\n Bonne journée. Il n’y a aucune soumission à noter pour ce devoir. Une erreur est survenue lors de la tentative d’affichage de cet élément de la liste des choses à faire. + Une erreur est survenu lors de la tentative d’affichage de cette tâche À Faire. L’inscription en tant que concepteur ne permet pas d’afficher ceci. Sections diff --git a/apps/teacher/src/main/res/values-ga/strings.xml b/apps/teacher/src/main/res/values-ga/strings.xml index 0c4b931bba..29217ceac9 100644 --- a/apps/teacher/src/main/res/values-ga/strings.xml +++ b/apps/teacher/src/main/res/values-ga/strings.xml @@ -31,6 +31,7 @@ Tinreamh Leathanaigh Le Déanamh + Le Déanamh Lógó na Scoile Cuardaigh na Treoracha Canvas Treoracha Canvas @@ -844,6 +845,7 @@ Níl aon rud níos mó le déanamh!\n Bain taitneamh as do lá. Níl aon uaslódálacha le marcáil don tasc seo. Tharla earráid agus an mhír Le Déanamh seo a fheiceáil. + Tharla earráid agus iarracht á déanamh an mhír Le Déanamh seo a fheiceáil. Ní féidir le rollú dearthóirí é seo a fheiceáil. Grúpaí diff --git a/apps/teacher/src/main/res/values-hi/strings.xml b/apps/teacher/src/main/res/values-hi/strings.xml index 21761edd75..cfa97dd05a 100644 --- a/apps/teacher/src/main/res/values-hi/strings.xml +++ b/apps/teacher/src/main/res/values-hi/strings.xml @@ -31,6 +31,7 @@ उपस्थिति पेज करने के लिए + करने के लिए स्कूल का लोगो Canvas गाइड में खोजें Canvas गाइड @@ -844,6 +845,7 @@ करके के लिए और कुछ नहीं है!\n अपने दिन का आनंद लें। इस असाइनमेंट को ग्रेड करने के लिए कोई सबमिशन नहीं है। इस करने के लिए आइटम को देखने की कोशिश करने के दौरान त्रुटि उत्पन्न हुई। + इस \'करने के लिए\' आइटम को देखने का प्रयास करते समय एक त्रुटि हुई। डिज़ाइनर नामांकन इसे नहीं देख सकता। अनुभाग diff --git a/apps/teacher/src/main/res/values-ht/strings.xml b/apps/teacher/src/main/res/values-ht/strings.xml index 6d9ab85cfc..65839e70a0 100644 --- a/apps/teacher/src/main/res/values-ht/strings.xml +++ b/apps/teacher/src/main/res/values-ht/strings.xml @@ -31,6 +31,7 @@ Prezans Paj Pou Fè + Pou fè moun Chèche nan Gid Canvas Gid Canvas @@ -842,6 +843,7 @@ Pa gen anyen ki pou fèt ankò!\n Pwofite jounen an. Pa gen soumisyon pou evalye pou sesyon sa a. Gen yon erè ki fèt pandan w ap eseye afiche eleman Tach sa a. + Yon erè te rive pandan w t ap eseye gade bagay pou w fè sa a. Enskripsyon konseptè paka wè sa a. Seksyon diff --git a/apps/teacher/src/main/res/values-id/strings.xml b/apps/teacher/src/main/res/values-id/strings.xml index 8160cd9934..0df5363951 100644 --- a/apps/teacher/src/main/res/values-id/strings.xml +++ b/apps/teacher/src/main/res/values-id/strings.xml @@ -31,6 +31,7 @@ Kehadiran Halaman Harus Dilakukan + To-do Logo Sekolah Cari di Panduan Canvas Panduan Canvas @@ -844,6 +845,7 @@ Tidak ada lagi hal yang harus dilakukan!\n Semoga harimu menyenangkan. Tidak ada penyerahan untuk dinilai untuk tugas ini. Kesalahan terjadi saat mencoba melihat item To Do ini. + Kesalahan terjadi saat mencoba melihat item To Do ini. Pendaftaran desainer tidak dapat melihat ini. Bagian diff --git a/apps/teacher/src/main/res/values-is/strings.xml b/apps/teacher/src/main/res/values-is/strings.xml index f544fc1310..6ee325f46f 100644 --- a/apps/teacher/src/main/res/values-is/strings.xml +++ b/apps/teacher/src/main/res/values-is/strings.xml @@ -31,6 +31,7 @@ Mæting Síður Verkefni + Verkefnalisti Myndmerki skóla Leita í leiðbeiningum Canvas Canvas leiðarvísar @@ -843,6 +844,7 @@ Ekkert annað á dagskrá!\n Njóttu dagsins. Þetta verkefni er ekki með nein skil sem bíða mats. Villa kom upp við að skoða þetta verkefni á verkefnalista. + Villa kom upp við að skoða þetta verkefni á verkefnalista. Hönnuðu innritun sér þetta ekki. Fylki diff --git a/apps/teacher/src/main/res/values-it/strings.xml b/apps/teacher/src/main/res/values-it/strings.xml index 7834756330..68e545bf6e 100644 --- a/apps/teacher/src/main/res/values-it/strings.xml +++ b/apps/teacher/src/main/res/values-it/strings.xml @@ -31,6 +31,7 @@ Frequenza Pagine Elenco attività + Attività da fare Logo della scuola Cerca Guide Canvas Guide Canvas @@ -843,6 +844,7 @@ Nessun\'altra attività!\n Buona giornata. Non ci sono consegne da valutare per questo compito. Si è verificato un errore durante il tentativo di visualizzare le cose da fare. + Si è verificato un errore durante il tentativo di visualizzare questo elemento da fare. L’iscrizione progettista non può visualizzare questo elemento. Sezioni diff --git a/apps/teacher/src/main/res/values-ja/strings.xml b/apps/teacher/src/main/res/values-ja/strings.xml index c3dd835ffa..fb274e8eef 100644 --- a/apps/teacher/src/main/res/values-ja/strings.xml +++ b/apps/teacher/src/main/res/values-ja/strings.xml @@ -31,6 +31,7 @@ 出席状況 ページ タスク + するべきこと スクールロゴ Canvas ガイドを検索する Canvas ガイド @@ -830,6 +831,7 @@ 他にすることはありません!\nよい一日を。 この課題を採点するための提出物はありません。 このやるべきことの項目を表示しようとしてエラーが発生しました。 + この「するべきこと」の項目を表示しようとしてエラーが発生しました。 デザイナー登録はこれを表示できません。 セクション diff --git a/apps/teacher/src/main/res/values-ko/strings.xml b/apps/teacher/src/main/res/values-ko/strings.xml index f90fdcf82b..c60bf1bb27 100644 --- a/apps/teacher/src/main/res/values-ko/strings.xml +++ b/apps/teacher/src/main/res/values-ko/strings.xml @@ -31,6 +31,7 @@ 출석 페이지 할 일 + 할 일 학교 로고 Canvas 가이드 검색 Canvas 가이드 @@ -832,6 +833,7 @@ 더 할 일이 없습니다.\n 즐거운 하루 보내세요. 이 과제에 대해 채점할 제출이 없습니다. 이 할 일 항목을 보려고 시도하는 데 오류가 발생했습니다. + 이 할 일 항목을 보려고 시도하는 데 오류가 발생했습니다. 디자이너 등록은 볼 수 없습니다. 분반 diff --git a/apps/teacher/src/main/res/values-mi/strings.xml b/apps/teacher/src/main/res/values-mi/strings.xml index 46c421575d..32eff8fff1 100644 --- a/apps/teacher/src/main/res/values-mi/strings.xml +++ b/apps/teacher/src/main/res/values-mi/strings.xml @@ -31,6 +31,7 @@ Taenga mai Ngā whārangi Hei mahi + Mahi-mahi Tohu o te Kura Rapu nga Kaiarahi Canvas Canvas Kaiārahi @@ -842,6 +843,7 @@ Kaore atu anō he mahi!\n Kia pai tō rā. Kaore he tukunga hei kōeke mo tēnei whakataunga. He hapa i puta i te wā e tiro ana i tēnei Hei Mahi tuemi. + I puta he hapa i te ngana ki te tiro i tēnei tūemi mahi. Kaore e taea e ngā kaitātai whakaurunga te tiro i tenei. Ngā Wāhanga diff --git a/apps/teacher/src/main/res/values-ms/strings.xml b/apps/teacher/src/main/res/values-ms/strings.xml index aed553386a..0d6a03ff4c 100644 --- a/apps/teacher/src/main/res/values-ms/strings.xml +++ b/apps/teacher/src/main/res/values-ms/strings.xml @@ -31,6 +31,7 @@ Kehadiran Halaman Untuk Dilakukan + Untuk Dilakukan Logo Sekolah Buat Carian dalam Panduan Canvas Panduan Canvas @@ -844,6 +845,7 @@ Tiada apa-apa lagi untuk dilakukan!\n Nikmati hari anda Tiada serahan untuk digredkan untuk tugasan ini. Ralat berlaku semasa cuba melihat Item Untuk Dilakukan + Ralat berlaku semasa cuba melihat item Untuk Dilakukan ini. Pendaftaran Pereka Bentuk tidak boleh melihat ini. Seksyen diff --git a/apps/teacher/src/main/res/values-nb/strings.xml b/apps/teacher/src/main/res/values-nb/strings.xml index 09addaaaba..5b8a88e1f5 100644 --- a/apps/teacher/src/main/res/values-nb/strings.xml +++ b/apps/teacher/src/main/res/values-nb/strings.xml @@ -31,6 +31,7 @@ Oppmøte Sider Å gjøre + Gjøremål Skolens Logo Søk i Canvas-guidene Canvas-guider @@ -844,6 +845,7 @@ Ingen flere oppgaver!\n Ha en fin dag. Det finnes ingen innleveringer å gi vurderinger til for denne oppgaven. En feil oppsto under visning av dette gjøremålselementet. + En feil oppsto under visning av dette gjøremålselementet. Designer påmelding kan ikke vise dette. Seksjoner diff --git a/apps/teacher/src/main/res/values-nl/strings.xml b/apps/teacher/src/main/res/values-nl/strings.xml index 612c064b7b..7ef51a9037 100644 --- a/apps/teacher/src/main/res/values-nl/strings.xml +++ b/apps/teacher/src/main/res/values-nl/strings.xml @@ -31,6 +31,7 @@ Aanwezigheid Pagina\'s To-do lijst + Opdracht Schoollogo In Canvas-handleidingen zoeken Canvas-handleidingen @@ -842,6 +843,7 @@ Dat was alles!\n Een fijne dag gewenst. Er zijn geen inzendingen die voor deze opdracht moeten worden beoordeeld. Er is een fout opgetreden bij het weergeven van dit To-do-item. + Er is een fout opgetreden bij het weergeven van dit item van de lijst met opdrachten. Ontwerperinschrijving kan dit niet zien. Secties diff --git a/apps/teacher/src/main/res/values-pl/strings.xml b/apps/teacher/src/main/res/values-pl/strings.xml index eaa231cfd2..7267b8e824 100644 --- a/apps/teacher/src/main/res/values-pl/strings.xml +++ b/apps/teacher/src/main/res/values-pl/strings.xml @@ -31,6 +31,7 @@ Uczestnictwo Strony Lista zadań + Lista zadań Logo szkoły Wyszukaj przewodniki Canvas Przewodniki Canvas @@ -866,6 +867,7 @@ Nic więcej do zrobienia!\n Korzystaj z dnia. Brak zgłoszeń do oceny dla tego zadania. Podczas wyświetlania tego elementu listy zadań wystąpił błąd. + Podczas wyświetlania tego elementu listy zadań wystąpił błąd. Rejestrujący nie może tego wyświetlać. Sekcje diff --git a/apps/teacher/src/main/res/values-pt-rBR/strings.xml b/apps/teacher/src/main/res/values-pt-rBR/strings.xml index bc677ae43d..f4aa047c1a 100644 --- a/apps/teacher/src/main/res/values-pt-rBR/strings.xml +++ b/apps/teacher/src/main/res/values-pt-rBR/strings.xml @@ -31,6 +31,7 @@ Frequência Páginas Lista de Tarefas + A fazer Logo da Escola Pesquisar no Canvas Guides Canvas Guides @@ -843,6 +844,7 @@ Nada mais para fazer!\n Aproveite o seu dia. Não há envios para avaliar para essa tarefa. Ocorreu um erro ao tentar visualizar o item da lista de tarefas. + Ocorreu um erro ao tentar visualizar este item da lista de tarefas. A matrícula de designer não pode visualizar isso. Turmas diff --git a/apps/teacher/src/main/res/values-pt-rPT/strings.xml b/apps/teacher/src/main/res/values-pt-rPT/strings.xml index eae07b0fe4..87fdb12b5d 100644 --- a/apps/teacher/src/main/res/values-pt-rPT/strings.xml +++ b/apps/teacher/src/main/res/values-pt-rPT/strings.xml @@ -31,6 +31,7 @@ Presença Páginas A Fazer + A fazer Logo da escola Procurar nos Guias do Canvas Guias Canvas @@ -842,6 +843,7 @@ Nada mais a fazer!\n Aproveite seu dia. Não há envios para classificação para esta tarefa. Ocorreu um erro ao tentar exibir este item para fazer. + Ocorreu um erro ao tentar exibir este item a fazer. Inscrição do Designer não pode ver isto. Secções diff --git a/apps/teacher/src/main/res/values-ru/strings.xml b/apps/teacher/src/main/res/values-ru/strings.xml index a766fefe94..3b1973c91f 100644 --- a/apps/teacher/src/main/res/values-ru/strings.xml +++ b/apps/teacher/src/main/res/values-ru/strings.xml @@ -31,6 +31,7 @@ Посещаемость Страницы Задачи + Задача Логотип учебного заведения Поиск в руководствах Canvas Руководства Canvas @@ -866,6 +867,7 @@ Все задания выполнены!\n Хорошего дня. Для этого задания отсутствуют объекты для оценки. Произошла ошибка при попытке просмотра этого элемента списка текущих дел. + Произошла ошибка при попытке просмотра этого элемента списка текущих задач. Это не доступно для просмотра при зачислении дизайнеров. Разделы diff --git a/apps/teacher/src/main/res/values-sl/strings.xml b/apps/teacher/src/main/res/values-sl/strings.xml index ec0023df01..735fc3743c 100644 --- a/apps/teacher/src/main/res/values-sl/strings.xml +++ b/apps/teacher/src/main/res/values-sl/strings.xml @@ -31,6 +31,7 @@ Prisotnost Strani Seznam opravil + Opravilo Logotip šole Išči vodnike po sistemu Canvas Vodniki po sistemu Canvas @@ -842,6 +843,7 @@ Ničesar ni več treba opraviti.\n Uživajte v dnevu. Za to nalogo ni oddaj, ki bi jih bilo treba oceniti. Pri ogledu tega elementa seznama opravil je prišlo do napake. + Pri ogledu tega elementa seznama opravil je prišlo do napake. Za vpis snovalca ogled tega ni mogoč. Sekcije diff --git a/apps/teacher/src/main/res/values-sv/strings.xml b/apps/teacher/src/main/res/values-sv/strings.xml index 71c762669d..a1193b3517 100644 --- a/apps/teacher/src/main/res/values-sv/strings.xml +++ b/apps/teacher/src/main/res/values-sv/strings.xml @@ -31,6 +31,7 @@ Närvaro Sidor Att göra + Att göra Skolans logotyp Sök i Canvas-guider Canvas-guider @@ -843,6 +844,7 @@ Inget mer att göra!\n Ha en bra dag. Det finns inga inlämningar att bedöma för denna uppgift. Ett fel uppstod när du försökte visa detta att göra-objekt. + Ett fel inträffade vid försök att visa det här att göra-objektet. Designerregistrering kan inte visa detta. Sektioner diff --git a/apps/teacher/src/main/res/values-th/strings.xml b/apps/teacher/src/main/res/values-th/strings.xml index 46b3dcf62c..991e2050ba 100644 --- a/apps/teacher/src/main/res/values-th/strings.xml +++ b/apps/teacher/src/main/res/values-th/strings.xml @@ -31,6 +31,7 @@ การเข้าร่วม เพจ สิ่งที่ต้องดำเนินการ + สิ่งที่ต้องทำ โลโก้สถานศึกษา ค้นหาคู่มือ Canvas คู่มือ Canvas @@ -844,6 +845,7 @@ ไม่มีการดำเนินการใด ๆ อีก!\n ขอให้มีความสุข ไม่มีผลงานจัดส่งที่จะให้คะแนนสำหรับภารกิจนี้ เกิดข้อผิดพลาดขณะพยายามดูรายการที่ต้องทำนี้ + เกิดข้อผิดพลาดขณะพยายามดูรายการที่ต้องทำ (To-do) นี้ ส่วนการลงทะเบียนของผู้ออกแบบไม่สามารถดูในส่วนนี้ได้ กลุ่มย่อย diff --git a/apps/teacher/src/main/res/values-vi/strings.xml b/apps/teacher/src/main/res/values-vi/strings.xml index 2fa202b268..8e93ea613c 100644 --- a/apps/teacher/src/main/res/values-vi/strings.xml +++ b/apps/teacher/src/main/res/values-vi/strings.xml @@ -31,6 +31,7 @@ Chuyên Cần Trang Việc Cần Làm + Việc Cần Làm Biểu Trưng Trường Tìm kiếm Canvas Guides Canvas Guides @@ -844,6 +845,7 @@ Không còn gì thêm cần làm!\n Hãy tận hưởng thời gian còn lại trong ngày nhé. Không có bài nộp để chấm điểm cho bài tập này. Đã xảy ra lỗi khi thử xem mục Việc Cần Làm. + Đã xảy ra lỗi khi thử xem mục Việc Cần Làm. Vai trò người thiết kế/ghi danh không xem được Phần diff --git a/apps/teacher/src/main/res/values-zh/strings.xml b/apps/teacher/src/main/res/values-zh/strings.xml index 934a615f29..b66b6b5697 100644 --- a/apps/teacher/src/main/res/values-zh/strings.xml +++ b/apps/teacher/src/main/res/values-zh/strings.xml @@ -31,6 +31,7 @@ 出勤 页面 待办事项 + 待办 学校徽标 搜索 Canvas 指南 Canvas 指南 @@ -830,6 +831,7 @@ 无需再执行其他操作!\n祝您度过愉快的一天。 此作业没有要评分的提交项。 尝试查看此待办事项时出错。 + 尝试查看此待办任务时出错。 设计者注册无法查看。 章节 diff --git a/apps/teacher/src/main/res/values/strings.xml b/apps/teacher/src/main/res/values/strings.xml index ae9fe72034..8e66b64ee2 100644 --- a/apps/teacher/src/main/res/values/strings.xml +++ b/apps/teacher/src/main/res/values/strings.xml @@ -31,6 +31,7 @@ Attendance Pages To Do + To-do School Logo Search the Canvas Guides Canvas Guides @@ -845,6 +846,7 @@ Nothing more to do!\n Enjoy your day. There are no submissions to grade for this assignment. An error occurred trying to view this To Do item. + An error occurred trying to view this To-do item. Designer enrollment cannot view this. Sections diff --git a/apps/teacher/src/main/res/values/styles.xml b/apps/teacher/src/main/res/values/styles.xml index b074fd0ce6..96d5b6ba8c 100644 --- a/apps/teacher/src/main/res/values/styles.xml +++ b/apps/teacher/src/main/res/values/styles.xml @@ -30,7 +30,6 @@ @style/Widget.ActionButton.Overflow @color/backgroundLight @style/DatePickerStyle - true @@ -107,7 +105,6 @@ @style/PropertyInspector @color/backgroundLight @style/ModalDialogStyle - true \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModelTest.kt index f45de8e061..3a4a6de482 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/chat/AiAssistChatViewModelTest.kt @@ -17,9 +17,9 @@ package com.instructure.horizon.features.aiassistant.chat import androidx.compose.ui.text.input.TextFieldValue -import com.instructure.canvasapi2.models.journey.JourneyAssistChipOption -import com.instructure.canvasapi2.models.journey.JourneyAssistRole -import com.instructure.canvasapi2.models.journey.JourneyAssistState +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistChipOption +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistRole +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistState import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.AiAssistRepository import com.instructure.horizon.features.aiassistant.common.AiAssistResponse diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepositoryTest.kt index bd61791e23..f63d58bcf7 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/common/AiAssistRepositoryTest.kt @@ -17,15 +17,15 @@ package com.instructure.horizon.features.aiassistant.common import com.instructure.canvasapi2.apis.JourneyAssistAPI -import com.instructure.canvasapi2.models.journey.JourneyAssistChatMessage -import com.instructure.canvasapi2.models.journey.JourneyAssistChipOption -import com.instructure.canvasapi2.models.journey.JourneyAssistCitation -import com.instructure.canvasapi2.models.journey.JourneyAssistCitationType -import com.instructure.canvasapi2.models.journey.JourneyAssistFlashCard -import com.instructure.canvasapi2.models.journey.JourneyAssistQuizItem -import com.instructure.canvasapi2.models.journey.JourneyAssistResponse -import com.instructure.canvasapi2.models.journey.JourneyAssistRole -import com.instructure.canvasapi2.models.journey.JourneyAssistState +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistChatMessage +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistChipOption +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistCitation +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistCitationType +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistFlashCard +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistQuizItem +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistResponse +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistRole +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistState import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/flashcard/AiAssistFlashcardViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/flashcard/AiAssistFlashcardViewModelTest.kt index 8e20decf62..98ff87ca1b 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/flashcard/AiAssistFlashcardViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/flashcard/AiAssistFlashcardViewModelTest.kt @@ -16,9 +16,9 @@ */ package com.instructure.horizon.features.aiassistant.flashcard -import com.instructure.canvasapi2.models.journey.JourneyAssistFlashCard -import com.instructure.canvasapi2.models.journey.JourneyAssistRole -import com.instructure.canvasapi2.models.journey.JourneyAssistState +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistFlashCard +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistRole +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistState import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.AiAssistRepository import com.instructure.horizon.features.aiassistant.common.AiAssistResponse diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/main/AiAssistMainViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/main/AiAssistMainViewModelTest.kt index 3390c63753..61f725ed57 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/main/AiAssistMainViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/main/AiAssistMainViewModelTest.kt @@ -16,8 +16,8 @@ */ package com.instructure.horizon.features.aiassistant.main -import com.instructure.canvasapi2.models.journey.JourneyAssistRole -import com.instructure.canvasapi2.models.journey.JourneyAssistState +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistRole +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistState import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.AiAssistRepository import com.instructure.horizon.features.aiassistant.common.AiAssistResponse diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/quiz/AiAssistQuizViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/quiz/AiAssistQuizViewModelTest.kt index 3fe5ed1276..af5791bb7d 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/quiz/AiAssistQuizViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/aiassistant/quiz/AiAssistQuizViewModelTest.kt @@ -16,9 +16,9 @@ */ package com.instructure.horizon.features.aiassistant.quiz -import com.instructure.canvasapi2.models.journey.JourneyAssistQuizItem -import com.instructure.canvasapi2.models.journey.JourneyAssistRole -import com.instructure.canvasapi2.models.journey.JourneyAssistState +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistQuizItem +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistRole +import com.instructure.canvasapi2.models.journey.assist.JourneyAssistState import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.AiAssistRepository import com.instructure.horizon.features.aiassistant.common.AiAssistResponse diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnRepositoryTest.kt deleted file mode 100644 index 391ff0f645..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnRepositoryTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn - -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager -import com.instructure.canvasapi2.models.User -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class LearnRepositoryTest { - private val horizonGetCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) - private val getProgramsManager: GetProgramsManager = mockk(relaxed = true) - private val apiPrefs: ApiPrefs = mockk(relaxed = true) - - private val testUser = User(id = 123L) - private val coursesWithProgress = listOf( - CourseWithProgress( - courseId = 1L, - courseName = "Course 1", - courseImageUrl = "https://example.com/image1.png", - progress = 50.0, - courseSyllabus = "Syllabus 1" - ), - CourseWithProgress( - courseId = 2L, - courseName = "Course 2", - courseImageUrl = "https://example.com/image2.png", - progress = 100.0, - courseSyllabus = "Syllabus 2" - ) - ) - - @Before - fun setup() { - every { apiPrefs.user } returns testUser - coEvery { horizonGetCoursesManager.getCoursesWithProgress(any(), any()) } returns DataResult.Success(coursesWithProgress) - } - - @Test - fun `getCoursesWithProgress returns list of courses with progress`() = runTest { - val repository = getRepository() - val result = repository.getCoursesWithProgress(false) - - assertEquals(2, result.size) - assertEquals(coursesWithProgress, result) - coVerify { horizonGetCoursesManager.getCoursesWithProgress(123L, false) } - } - - @Test - fun `getCoursesWithProgress with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getCoursesWithProgress(true) - - coVerify { horizonGetCoursesManager.getCoursesWithProgress(123L, true) } - } - - @Test - fun `getCoursesWithProgress uses -1 when user is null`() = runTest { - every { apiPrefs.user } returns null - val repository = getRepository() - repository.getCoursesWithProgress(false) - - coVerify { horizonGetCoursesManager.getCoursesWithProgress(-1L, false) } - } - - @Test - fun `getCoursesById returns list of courses`() = runTest { - val courseIds = listOf(1L, 2L, 3L) - val expectedCourses = courseIds.map { id -> - CourseWithModuleItemDurations( - courseId = id, - courseName = "Course $id", - moduleItemsDuration = listOf("30m", "45m"), - startDate = null, - endDate = null - ) - } - - coEvery { horizonGetCoursesManager.getProgramCourses(1L, false) } returns DataResult.Success(expectedCourses[0]) - coEvery { horizonGetCoursesManager.getProgramCourses(2L, false) } returns DataResult.Success(expectedCourses[1]) - coEvery { horizonGetCoursesManager.getProgramCourses(3L, false) } returns DataResult.Success(expectedCourses[2]) - - val repository = getRepository() - val result = repository.getCoursesById(courseIds) - - assertEquals(3, result.size) - assertEquals(expectedCourses, result) - coVerify { horizonGetCoursesManager.getProgramCourses(1L, false) } - coVerify { horizonGetCoursesManager.getProgramCourses(2L, false) } - coVerify { horizonGetCoursesManager.getProgramCourses(3L, false) } - } - - @Test - fun `getCoursesById with forceNetwork true calls API with force network`() = runTest { - val courseIds = listOf(1L) - val course = CourseWithModuleItemDurations( - courseId = 1L, - courseName = "Course 1", - moduleItemsDuration = listOf("30m"), - startDate = null, - endDate = null - ) - coEvery { horizonGetCoursesManager.getProgramCourses(1L, true) } returns DataResult.Success(course) - - val repository = getRepository() - repository.getCoursesById(courseIds, forceNetwork = true) - - coVerify { horizonGetCoursesManager.getProgramCourses(1L, true) } - } - - @Test - fun `getCoursesById with empty list returns empty list`() = runTest { - val repository = getRepository() - val result = repository.getCoursesById(emptyList()) - - assertEquals(0, result.size) - } - - private fun getRepository(): LearnRepository { - return LearnRepository(horizonGetCoursesManager, getProgramsManager, apiPrefs) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnViewModelTest.kt deleted file mode 100644 index 08f77ed2de..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnViewModelTest.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn - -import androidx.lifecycle.SavedStateHandle -import com.instructure.horizon.features.learn.navigation.LearnRoute -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class LearnViewModelTest { - private val testDispatcher = UnconfinedTestDispatcher() - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `Initial state has COURSES tab selected by default`() { - val savedStateHandle = SavedStateHandle() - val viewModel = LearnViewModel(savedStateHandle) - - val state = viewModel.state.value - assertEquals(LearnTab.COURSES, state.selectedTab) - assertEquals(LearnTab.entries, state.tabs) - } - - @Test - fun `Initial state restores selected tab from SavedStateHandle when provided`() { - val savedStateHandle = SavedStateHandle(mapOf( - LearnRoute.LearnScreen.selectedTabAttr to "programs" - )) - val viewModel = LearnViewModel(savedStateHandle) - - val state = viewModel.state.value - assertEquals(LearnTab.PROGRAMS, state.selectedTab) - } - - @Test - fun `Initial state ignores invalid tab string from SavedStateHandle`() { - val savedStateHandle = SavedStateHandle(mapOf( - LearnRoute.LearnScreen.selectedTabAttr to "invalid_tab" - )) - val viewModel = LearnViewModel(savedStateHandle) - - val state = viewModel.state.value - assertEquals(LearnTab.COURSES, state.selectedTab) - } - - @Test - fun `updateSelectedTabIndex updates selected tab`() { - val savedStateHandle = SavedStateHandle() - val viewModel = LearnViewModel(savedStateHandle) - - viewModel.state.value.updateSelectedTabIndex(1) - - val state = viewModel.state.value - assertEquals(LearnTab.PROGRAMS, state.selectedTab) - } - - @Test - fun `updateSelectedTabIndex with 0 selects COURSES tab`() { - val savedStateHandle = SavedStateHandle(mapOf( - LearnRoute.LearnScreen.selectedTabAttr to "programs" - )) - val viewModel = LearnViewModel(savedStateHandle) - - viewModel.state.value.updateSelectedTabIndex(0) - - val state = viewModel.state.value - assertEquals(LearnTab.COURSES, state.selectedTab) - } - - @Test - fun `updateSelectedTab updates selected tab by string value`() { - val savedStateHandle = SavedStateHandle() - val viewModel = LearnViewModel(savedStateHandle) - - viewModel.state.value.updateSelectedTab("programs") - - val state = viewModel.state.value - assertEquals(LearnTab.PROGRAMS, state.selectedTab) - } - - @Test - fun `updateSelectedTab with courses string selects COURSES tab`() { - val savedStateHandle = SavedStateHandle(mapOf( - LearnRoute.LearnScreen.selectedTabAttr to "programs" - )) - val viewModel = LearnViewModel(savedStateHandle) - - viewModel.state.value.updateSelectedTab("courses") - - val state = viewModel.state.value - assertEquals(LearnTab.COURSES, state.selectedTab) - } - - @Test - fun `updateSelectedTab with invalid string does not change tab`() { - val savedStateHandle = SavedStateHandle() - val viewModel = LearnViewModel(savedStateHandle) - val initialTab = viewModel.state.value.selectedTab - - viewModel.state.value.updateSelectedTab("invalid_value") - - val state = viewModel.state.value - assertEquals(initialTab, state.selectedTab) - } - - @Test - fun `State callbacks are properly initialized`() { - val savedStateHandle = SavedStateHandle() - val viewModel = LearnViewModel(savedStateHandle) - - val state = viewModel.state.value - assertEquals(LearnTab.entries, state.tabs) - assertEquals(LearnTab.COURSES, state.selectedTab) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/list/LearnCourseListRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/list/LearnCourseListRepositoryTest.kt deleted file mode 100644 index 88ed81cad2..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/list/LearnCourseListRepositoryTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.list - -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager -import com.instructure.canvasapi2.models.User -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class LearnCourseListRepositoryTest { - private val horizonGetCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) - private val apiPrefs: ApiPrefs = mockk(relaxed = true) - - private val testUser = User(id = 456L) - private val coursesWithProgress = listOf( - CourseWithProgress( - courseId = 1L, - courseName = "Introduction to Programming", - courseImageUrl = "https://example.com/prog.png", - progress = 0.0, - courseSyllabus = "Learn programming basics" - ), - CourseWithProgress( - courseId = 2L, - courseName = "Advanced Mathematics", - courseImageUrl = "https://example.com/math.png", - progress = 50.0, - courseSyllabus = "Advanced math topics" - ), - CourseWithProgress( - courseId = 3L, - courseName = "Web Development", - courseImageUrl = "https://example.com/web.png", - progress = 100.0, - courseSyllabus = "Build modern websites" - ) - ) - - @Before - fun setup() { - every { apiPrefs.user } returns testUser - coEvery { horizonGetCoursesManager.getCoursesWithProgress(any(), any()) } returns DataResult.Success(coursesWithProgress) - } - - @Test - fun `getCoursesWithProgress returns list of courses`() = runTest { - val repository = getRepository() - val result = repository.getCoursesWithProgress(false) - - assertEquals(3, result.size) - assertEquals(coursesWithProgress, result) - coVerify { horizonGetCoursesManager.getCoursesWithProgress(456L, false) } - } - - @Test - fun `getCoursesWithProgress with forceNetwork true calls API with force network`() = runTest { - val repository = getRepository() - repository.getCoursesWithProgress(true) - - coVerify { horizonGetCoursesManager.getCoursesWithProgress(456L, true) } - } - - @Test - fun `getCoursesWithProgress uses -1 when user is null`() = runTest { - every { apiPrefs.user } returns null - val repository = getRepository() - repository.getCoursesWithProgress(false) - - coVerify { horizonGetCoursesManager.getCoursesWithProgress(-1L, false) } - } - - @Test - fun `getCoursesWithProgress returns empty list when no courses`() = runTest { - coEvery { horizonGetCoursesManager.getCoursesWithProgress(any(), any()) } returns DataResult.Success(emptyList()) - val repository = getRepository() - val result = repository.getCoursesWithProgress(false) - - assertEquals(0, result.size) - } - - private fun getRepository(): LearnCourseListRepository { - return LearnCourseListRepository(horizonGetCoursesManager, apiPrefs) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/list/LearnCourseListViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/list/LearnCourseListViewModelTest.kt deleted file mode 100644 index e9b1493eb1..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/course/list/LearnCourseListViewModelTest.kt +++ /dev/null @@ -1,362 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.course.list - -import android.content.Context -import androidx.compose.ui.text.input.TextFieldValue -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertFalse -import junit.framework.TestCase.assertNull -import junit.framework.TestCase.assertTrue -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class LearnCourseListViewModelTest { - private val context: Context = mockk(relaxed = true) - private val repository: LearnCourseListRepository = mockk(relaxed = true) - private val testDispatcher = UnconfinedTestDispatcher() - - private val testCourses = listOf( - CourseWithProgress( - courseId = 1L, - courseName = "Introduction to Programming", - courseImageUrl = "https://example.com/prog.png", - progress = 0.0, - courseSyllabus = "Learn programming basics" - ), - CourseWithProgress( - courseId = 2L, - courseName = "Advanced Mathematics", - courseImageUrl = "https://example.com/math.png", - progress = 50.0, - courseSyllabus = "Advanced math topics" - ), - CourseWithProgress( - courseId = 3L, - courseName = "Web Development", - courseImageUrl = "https://example.com/web.png", - progress = 100.0, - courseSyllabus = "Build modern websites" - ), - CourseWithProgress( - courseId = 4L, - courseName = "Data Science", - courseImageUrl = "https://example.com/data.png", - progress = 25.0, - courseSyllabus = "Analyze data" - ) - ) - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - coEvery { repository.getCoursesWithProgress(any()) } returns testCourses - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `Initial state loads courses successfully`() { - val viewModel = getViewModel() - - val state = viewModel.state.value - assertFalse(state.loadingState.isLoading) - assertFalse(state.loadingState.isError) - assertEquals(4, state.coursesToDisplay.size) - coVerify { repository.getCoursesWithProgress(false) } - } - - @Test - fun `Initial state sets default filter to All`() { - val viewModel = getViewModel() - - val state = viewModel.state.value - assertEquals(LearnCourseFilterOption.All, state.selectedFilterValue) - } - - @Test - fun `Initial state sets visible item count to 10`() { - val viewModel = getViewModel() - - val state = viewModel.state.value - assertEquals(10, state.visibleItemCount) - } - - @Test - fun `Initial state has empty search query`() { - val viewModel = getViewModel() - - val state = viewModel.state.value - assertEquals("", state.searchQuery.text) - } - - @Test - fun `Loading state shows error when repository fails`() { - coEvery { repository.getCoursesWithProgress(any()) } throws Exception("Network error") - val viewModel = getViewModel() - - val state = viewModel.state.value - assertTrue(state.loadingState.isError) - assertFalse(state.loadingState.isLoading) - } - - @Test - fun `Filter by NotStarted shows only courses with 0 progress`() { - val viewModel = getViewModel() - - viewModel.state.value.updateFilterValue(LearnCourseFilterOption.NotStarted) - - val state = viewModel.state.value - assertEquals(1, state.coursesToDisplay.size) - assertEquals("Introduction to Programming", state.coursesToDisplay[0].courseName) - assertEquals(0.0, state.coursesToDisplay[0].progress) - } - - @Test - fun `Filter by InProgress shows only courses with 0-100 progress`() { - val viewModel = getViewModel() - - viewModel.state.value.updateFilterValue(LearnCourseFilterOption.InProgress) - - val state = viewModel.state.value - assertEquals(2, state.coursesToDisplay.size) - assertTrue(state.coursesToDisplay.any { it.courseName == "Advanced Mathematics" }) - assertTrue(state.coursesToDisplay.any { it.courseName == "Data Science" }) - } - - @Test - fun `Filter by Completed shows only courses with 100 progress`() { - val viewModel = getViewModel() - - viewModel.state.value.updateFilterValue(LearnCourseFilterOption.Completed) - - val state = viewModel.state.value - assertEquals(1, state.coursesToDisplay.size) - assertEquals("Web Development", state.coursesToDisplay[0].courseName) - assertEquals(100.0, state.coursesToDisplay[0].progress) - } - - @Test - fun `Filter by All shows all courses`() { - val viewModel = getViewModel() - viewModel.state.value.updateFilterValue(LearnCourseFilterOption.NotStarted) - - viewModel.state.value.updateFilterValue(LearnCourseFilterOption.All) - - val state = viewModel.state.value - assertEquals(4, state.coursesToDisplay.size) - } - - @Test - fun `Search query filters courses by name case-insensitive`() { - val viewModel = getViewModel() - - viewModel.state.value.updateSearchQuery(TextFieldValue("programming")) - - val state = viewModel.state.value - assertEquals(1, state.coursesToDisplay.size) - assertEquals("Introduction to Programming", state.coursesToDisplay[0].courseName) - } - - @Test - fun `Search query with partial match filters correctly`() { - val viewModel = getViewModel() - - viewModel.state.value.updateSearchQuery(TextFieldValue("dev")) - - val state = viewModel.state.value - assertEquals(1, state.coursesToDisplay.size) - assertEquals("Web Development", state.coursesToDisplay[0].courseName) - } - - @Test - fun `Search query with no match returns empty list`() { - val viewModel = getViewModel() - - viewModel.state.value.updateSearchQuery(TextFieldValue("NonExistentCourse")) - - val state = viewModel.state.value - assertEquals(0, state.coursesToDisplay.size) - } - - @Test - fun `Search query trims whitespace`() { - val viewModel = getViewModel() - - viewModel.state.value.updateSearchQuery(TextFieldValue(" mathematics ")) - - val state = viewModel.state.value - assertEquals(1, state.coursesToDisplay.size) - assertEquals("Advanced Mathematics", state.coursesToDisplay[0].courseName) - } - - @Test - fun `Combined filter and search works correctly`() { - val viewModel = getViewModel() - - viewModel.state.value.updateFilterValue(LearnCourseFilterOption.InProgress) - viewModel.state.value.updateSearchQuery(TextFieldValue("math")) - - val state = viewModel.state.value - assertEquals(1, state.coursesToDisplay.size) - assertEquals("Advanced Mathematics", state.coursesToDisplay[0].courseName) - } - - @Test - fun `Combined filter and search with no match returns empty list`() { - val viewModel = getViewModel() - - viewModel.state.value.updateFilterValue(LearnCourseFilterOption.Completed) - viewModel.state.value.updateSearchQuery(TextFieldValue("programming")) - - val state = viewModel.state.value - assertEquals(0, state.coursesToDisplay.size) - } - - @Test - fun `increaseVisibleItemCount increases count by 10`() { - val viewModel = getViewModel() - val initialCount = viewModel.state.value.visibleItemCount - - viewModel.state.value.increaseVisibleItemCount() - - val state = viewModel.state.value - assertEquals(initialCount + 10, state.visibleItemCount) - } - - @Test - fun `Multiple increaseVisibleItemCount calls accumulate`() { - val viewModel = getViewModel() - - viewModel.state.value.increaseVisibleItemCount() - viewModel.state.value.increaseVisibleItemCount() - - val state = viewModel.state.value - assertEquals(30, state.visibleItemCount) - } - - @Test - fun `Refresh calls repository with forceNetwork true`() { - val viewModel = getViewModel() - - viewModel.state.value.loadingState.onRefresh() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { repository.getCoursesWithProgress(true) } - } - - @Test - fun `Refresh updates courses list`() { - val viewModel = getViewModel() - val updatedCourses = listOf( - CourseWithProgress( - courseId = 5L, - courseName = "New Course", - courseImageUrl = "https://example.com/new.png", - progress = 0.0, - courseSyllabus = "New syllabus" - ) - ) - coEvery { repository.getCoursesWithProgress(true) } returns updatedCourses - - viewModel.state.value.loadingState.onRefresh() - testDispatcher.scheduler.advanceUntilIdle() - - val state = viewModel.state.value - assertFalse(state.loadingState.isRefreshing) - assertEquals(1, state.coursesToDisplay.size) - assertEquals("New Course", state.coursesToDisplay[0].courseName) - } - - @Test - fun `Refresh on error shows snackbar message`() { - val viewModel = getViewModel() - coEvery { repository.getCoursesWithProgress(true) } throws Exception("Network error") - - viewModel.state.value.loadingState.onRefresh() - testDispatcher.scheduler.advanceUntilIdle() - - val state = viewModel.state.value - assertFalse(state.loadingState.isRefreshing) - assertTrue(state.loadingState.snackbarMessage != null) - } - - @Test - fun `Dismiss snackbar clears snackbar message`() { - val viewModel = getViewModel() - coEvery { repository.getCoursesWithProgress(true) } throws Exception("Network error") - viewModel.state.value.loadingState.onRefresh() - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.state.value.loadingState.onSnackbarDismiss() - - val state = viewModel.state.value - assertNull(state.loadingState.snackbarMessage) - } - - @Test - fun `Courses are mapped correctly to LearnCourseState`() { - val viewModel = getViewModel() - - val state = viewModel.state.value - val firstCourse = state.coursesToDisplay[0] - assertEquals(1L, firstCourse.courseId) - assertEquals("Introduction to Programming", firstCourse.courseName) - assertEquals("https://example.com/prog.png", firstCourse.imageUrl) - assertEquals(0.0, firstCourse.progress) - } - - @Test - fun `Changing filter resets to filtered view of all loaded courses`() { - val viewModel = getViewModel() - viewModel.state.value.updateSearchQuery(TextFieldValue("programming")) - - viewModel.state.value.updateFilterValue(LearnCourseFilterOption.InProgress) - - val state = viewModel.state.value - assertEquals(0, state.coursesToDisplay.size) - } - - @Test - fun `Empty courses list loads successfully`() { - coEvery { repository.getCoursesWithProgress(any()) } returns emptyList() - val viewModel = getViewModel() - - val state = viewModel.state.value - assertFalse(state.loadingState.isLoading) - assertFalse(state.loadingState.isError) - assertEquals(0, state.coursesToDisplay.size) - } - - private fun getViewModel(): LearnCourseListViewModel { - return LearnCourseListViewModel(context, repository) - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/filter/LearnLearningLibraryFilterViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/filter/LearnLearningLibraryFilterViewModelTest.kt new file mode 100644 index 0000000000..8b3b760c19 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/filter/LearnLearningLibraryFilterViewModelTest.kt @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.filter + +import android.content.res.Resources +import androidx.lifecycle.SavedStateHandle +import com.instructure.horizon.features.learn.LearnEvent +import com.instructure.horizon.features.learn.LearnEventHandler +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryFilterScreenType +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter +import com.instructure.horizon.features.learn.navigation.LearnRoute +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LearnLearningLibraryFilterViewModelTest { + + private val resources: Resources = mockk(relaxed = true) + private val eventHandler = LearnEventHandler() + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + every { resources.getString(any()) } returns "" + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Browse screenType includes standard item type filters excluding Programs`() = runTest { + every { resources.getString(LearnLearningLibraryTypeFilter.Programs.labelRes) } returns "Programs" + val viewModel = getViewModel(screenType = LearnLearningLibraryFilterScreenType.Browse) + + val typeSection = viewModel.uiState.value.sections.last() + val labels = typeSection.items.map { it.label } + + assertTrue(labels.none { it == "Programs" }) + assertTrue(typeSection.items.size > 2) + } + + @Test + fun `MyContent screenType includes only All, Programs and Courses filters`() = runTest { + every { resources.getString(LearnLearningLibraryTypeFilter.All.labelRes) } returns "All" + every { resources.getString(LearnLearningLibraryTypeFilter.Programs.labelRes) } returns "Programs" + every { resources.getString(LearnLearningLibraryTypeFilter.Courses.labelRes) } returns "Courses" + val viewModel = getViewModel(screenType = LearnLearningLibraryFilterScreenType.MyContent) + + val typeSection = viewModel.uiState.value.sections.last() + + assertEquals(3, typeSection.items.size) + } + + @Test + fun `MyContentSaved screenType has same filter count as Browse`() = runTest { + val browseViewModel = getViewModel(screenType = LearnLearningLibraryFilterScreenType.Browse) + val savedViewModel = getViewModel(screenType = LearnLearningLibraryFilterScreenType.MyContentSaved) + + val browseCount = browseViewModel.uiState.value.sections.last().items.size + val savedCount = savedViewModel.uiState.value.sections.last().items.size + + assertEquals(browseCount, savedCount) + } + + @Test + fun `Initial state has MostRecent sort option selected`() = runTest { + val viewModel = getViewModel() + + val sortSection = viewModel.uiState.value.sections.first() + val mostRecentItem = sortSection.items.first() + + assertTrue(mostRecentItem.isSelected) + } + + @Test + fun `Initial state has All type filter selected`() = runTest { + val viewModel = getViewModel() + + val typeSection = viewModel.uiState.value.sections.last() + val allItem = typeSection.items.first() + + assertTrue(allItem.isSelected) + } + + @Test + fun `Selecting a sort option updates isSelected in state`() = runTest { + val viewModel = getViewModel() + val sortSection = viewModel.uiState.value.sections.first() + + sortSection.items[1].onSelected() + + val updatedSortSection = viewModel.uiState.value.sections.first() + assertFalse(updatedSortSection.items[0].isSelected) + assertTrue(updatedSortSection.items[1].isSelected) + } + + @Test + fun `Selecting a type filter updates isSelected in state`() = runTest { + val viewModel = getViewModel(screenType = LearnLearningLibraryFilterScreenType.MyContent) + val typeSection = viewModel.uiState.value.sections.last() + + typeSection.items[1].onSelected() + + val updatedTypeSection = viewModel.uiState.value.sections.last() + assertFalse(updatedTypeSection.items[0].isSelected) + assertTrue(updatedTypeSection.items[1].isSelected) + } + + @Test + fun `Selecting a type filter emits UpdateLearningLibraryFilter event`() = runTest { + val viewModel = getViewModel(screenType = LearnLearningLibraryFilterScreenType.MyContent) + val typeSection = viewModel.uiState.value.sections.last() + + var lastEvent: LearnEvent? = null + val job = launch(testDispatcher) { + eventHandler.events.collect { lastEvent = it } + } + + typeSection.items[2].onSelected() + + val event = lastEvent as? LearnEvent.UpdateLearningLibraryFilter + assertEquals(LearnLearningLibraryFilterScreenType.MyContent, event?.screenType) + job.cancel() + } + + @Test + fun `Selecting a sort option emits UpdateLearningLibraryFilter event with correct sort`() = runTest { + val viewModel = getViewModel() + val sortSection = viewModel.uiState.value.sections.first() + + var lastEvent: LearnEvent? = null + val job = launch(testDispatcher) { + eventHandler.events.collect { lastEvent = it } + } + + sortSection.items.last().onSelected() + + val event = lastEvent as? LearnEvent.UpdateLearningLibraryFilter + assertEquals(LearnLearningLibrarySortOption.NameDescending, event?.sortOption) + job.cancel() + } + + @Test + fun `clearFilters resets to All type filter and MostRecent sort`() = runTest { + val viewModel = getViewModel(screenType = LearnLearningLibraryFilterScreenType.MyContent) + viewModel.uiState.value.sections.last().items[1].onSelected() + viewModel.uiState.value.sections.first().items[2].onSelected() + + viewModel.uiState.value.onClearFilters() + + val sortSection = viewModel.uiState.value.sections.first() + val typeSection = viewModel.uiState.value.sections.last() + assertTrue(sortSection.items[0].isSelected) + assertTrue(typeSection.items[0].isSelected) + } + + @Test + fun `clearFilters emits UpdateLearningLibraryFilter with All and MostRecent`() = runTest { + val viewModel = getViewModel(screenType = LearnLearningLibraryFilterScreenType.Browse) + viewModel.uiState.value.sections.last().items[1].onSelected() + + var lastEvent: LearnEvent? = null + val job = launch(testDispatcher) { + eventHandler.events.collect { lastEvent = it } + } + + viewModel.uiState.value.onClearFilters() + + val event = lastEvent as? LearnEvent.UpdateLearningLibraryFilter + assertEquals(LearnLearningLibraryTypeFilter.All, event?.typeFilter) + assertEquals(LearnLearningLibrarySortOption.MostRecent, event?.sortOption) + job.cancel() + } + + @Test + fun `SavedStateHandle initializes typeFilter from saved args`() = runTest { + val viewModel = getViewModel( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + initialTypeFilter = LearnLearningLibraryTypeFilter.Courses, + ) + + val typeSection = viewModel.uiState.value.sections.last() + val coursesItem = typeSection.items.first { it.isSelected } + + assertTrue(coursesItem.isSelected) + } + + @Test + fun `SavedStateHandle initializes sortOption from saved args`() = runTest { + val viewModel = getViewModel( + initialSortOption = LearnLearningLibrarySortOption.NameAscending, + ) + + val sortSection = viewModel.uiState.value.sections.first() + val selectedItem = sortSection.items.first { it.isSelected } + + assertTrue(selectedItem.isSelected) + } + + @Test + fun `Two sort sections and type sections are always present`() = runTest { + val viewModel = getViewModel() + + assertEquals(2, viewModel.uiState.value.sections.size) + } + + private fun getViewModel( + screenType: LearnLearningLibraryFilterScreenType = LearnLearningLibraryFilterScreenType.Browse, + initialTypeFilter: LearnLearningLibraryTypeFilter = LearnLearningLibraryTypeFilter.All, + initialSortOption: LearnLearningLibrarySortOption = LearnLearningLibrarySortOption.MostRecent, + ): LearnLearningLibraryFilterViewModel { + val savedStateHandle = SavedStateHandle( + mapOf( + LearnRoute.LearnLearningLibraryFilterScreen.screenTypeAttr to screenType.name, + LearnRoute.LearnLearningLibraryFilterScreen.typeFilterAttr to initialTypeFilter.name, + LearnRoute.LearnLearningLibraryFilterScreen.sortOptionAttr to initialSortOption.name, + ) + ) + return LearnLearningLibraryFilterViewModel(resources, eventHandler, savedStateHandle) + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/details/LearnLearningLibraryDetailsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/details/LearnLearningLibraryDetailsRepositoryTest.kt new file mode 100644 index 0000000000..fa549e295d --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/details/LearnLearningLibraryDetailsRepositoryTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.learninglibrary.details + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager +import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.util.Date + +class LearnLearningLibraryDetailsRepositoryTest { + private val getLearningLibraryManager: GetLearningLibraryManager = mockk(relaxed = true) + + private val testCollection = EnrolledLearningLibraryCollection( + id = "collection1", + name = "Test Collection", + publicName = "Test Collection Public", + description = "Test description", + createdAt = Date(), + updatedAt = Date(), + totalItemCount = 3, + items = listOf( + createTestCollectionItem( + id = "item1", + courseName = "Course 1", + courseId = "1", + isBookmarked = false, + itemType = CollectionItemType.COURSE + ), + createTestCollectionItem( + id = "item2", + courseName = "Course 2", + courseId = "2", + isBookmarked = true, + itemType = CollectionItemType.PAGE + ), + createTestCollectionItem( + id = "item3", + courseName = "Assignment 1", + courseId = "3", + isBookmarked = false, + itemType = CollectionItemType.ASSIGNMENT + ) + ) + ) + + @Before + fun setup() { + coEvery { getLearningLibraryManager.getEnrolledLearningLibraryCollection(any(), any()) } returns testCollection + } + + @Test + fun `getLearningLibraryItems returns collection with items`() = runTest { + val repository = getRepository() + val result = repository.getLearningLibraryItems("collection1", false) + + assertEquals("Test Collection", result.name) + assertEquals(3, result.items.size) + assertEquals("Course 1", result.items[0].canvasCourse?.courseName) + coVerify { getLearningLibraryManager.getEnrolledLearningLibraryCollection("collection1", false) } + } + + @Test + fun `getLearningLibraryItems with forceRefresh true calls API with force refresh`() = runTest { + val repository = getRepository() + repository.getLearningLibraryItems("collection1", true) + + coVerify { getLearningLibraryManager.getEnrolledLearningLibraryCollection("collection1", true) } + } + + @Test + fun `getLearningLibraryItems returns empty items when collection has no items`() = runTest { + val emptyCollection = testCollection.copy(items = emptyList()) + coEvery { getLearningLibraryManager.getEnrolledLearningLibraryCollection(any(), any()) } returns emptyCollection + val repository = getRepository() + + val result = repository.getLearningLibraryItems("collection1", false) + + assertEquals(0, result.items.size) + } + + @Test + fun `toggleLearningLibraryItemIsBookmarked returns new bookmark state true`() = runTest { + coEvery { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item1") } returns true + val repository = getRepository() + + val result = repository.toggleLearningLibraryItemIsBookmarked("item1") + + assertTrue(result) + coVerify { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item1") } + } + + @Test + fun `toggleLearningLibraryItemIsBookmarked returns new bookmark state false`() = runTest { + coEvery { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item2") } returns false + val repository = getRepository() + + val result = repository.toggleLearningLibraryItemIsBookmarked("item2") + + assertFalse(result) + coVerify { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item2") } + } + + private fun getRepository(): LearnLearningLibraryDetailsRepository { + return LearnLearningLibraryDetailsRepository(getLearningLibraryManager) + } + + private fun createTestCollectionItem( + id: String, + courseName: String, + courseId: String, + isBookmarked: Boolean, + itemType: CollectionItemType, + isEnrolledInCanvas: Boolean = false + ): LearningLibraryCollectionItem = LearningLibraryCollectionItem( + id = id, + itemType = itemType, + isBookmarked = isBookmarked, + completionPercentage = 0.0, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasCourse = CanvasCourseInfo( + courseId = courseId, + courseName = courseName, + courseImageUrl = "https://example.com/$courseId.png", + estimatedDurationMinutes = 60.0, + moduleCount = 5.0, + moduleItemCount = 10.0, + canvasUrl = "https://example.com", + ), + libraryId = "library1", + displayOrder = 1.0, + programId = null, + programCourseId = null, + createdAt = Date(), + updatedAt = Date(), + moduleInfo = null, + canvasEnrollmentId = null + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/details/LearnLearningLibraryDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/details/LearnLearningLibraryDetailsViewModelTest.kt new file mode 100644 index 0000000000..0f0a198ab2 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/details/LearnLearningLibraryDetailsViewModelTest.kt @@ -0,0 +1,633 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.learninglibrary.details + +import android.content.res.Resources +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem +import com.instructure.horizon.R +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryStatusFilter +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter +import com.instructure.horizon.features.learn.navigation.LearnRoute +import com.instructure.pandautils.utils.ThemePrefs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class LearnLearningLibraryDetailsViewModelTest { + private val resources: Resources = mockk(relaxed = true) + private val repository: LearnLearningLibraryDetailsRepository = mockk(relaxed = true) + private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + private val testCollectionId = "testCollection123" + private val testCollection = createTestCollection( + id = testCollectionId, + name = "Software Engineering", + items = listOf( + createTestCollectionItem( + id = "item1", + courseName = "Introduction to Programming", + courseId = "1", + completionPercentage = 0.0, + isBookmarked = false, + itemType = CollectionItemType.COURSE + ), + createTestCollectionItem( + id = "item2", + courseName = "Advanced Algorithms", + courseId = "2", + completionPercentage = 50.0, + isBookmarked = true, + itemType = CollectionItemType.ASSIGNMENT + ), + createTestCollectionItem( + id = "item3", + courseName = "Data Structures", + courseId = "3", + completionPercentage = 100.0, + isBookmarked = false, + itemType = CollectionItemType.PAGE + ), + createTestCollectionItem( + id = "item4", + courseName = "Web Development Guide", + courseId = "4", + completionPercentage = 0.0, + isBookmarked = true, + itemType = CollectionItemType.FILE + ), + createTestCollectionItem( + id = "item5", + courseName = "External Learning Resource", + courseId = "5", + completionPercentage = 100.0, + isBookmarked = false, + itemType = CollectionItemType.EXTERNAL_URL + ), + createTestCollectionItem( + id = "item6", + courseName = "Tool Integration", + courseId = "6", + completionPercentage = 0.0, + isBookmarked = false, + itemType = CollectionItemType.EXTERNAL_TOOL + ) + ) + ) + + @Before + fun setup() { + mockkObject(ThemePrefs) + every { ThemePrefs.brandColor } returns 0xFF0000FF.toInt() + every { ThemePrefs.mobileLogoUrl } returns "https://example.com/logo.png" + Dispatchers.setMain(testDispatcher) + + every { resources.getString(any()) } returns "" + every { resources.getString(any(), *anyVararg()) } returns "" + every { resources.getQuantityString(any(), any()) } returns "items" + every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "items" + + every { savedStateHandle.get(LearnRoute.LearnLearningLibraryDetailsScreen.collectionIdAttr) } returns testCollectionId + + coEvery { repository.getLearningLibraryItems(any(), any()) } returns testCollection + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Initial state loads collection successfully`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertFalse(state.loadingState.isLoading) + assertFalse(state.loadingState.isError) + assertEquals("Software Engineering", state.collectionName) + assertEquals(6, state.items.size) + coVerify { repository.getLearningLibraryItems(testCollectionId, false) } + } + + @Test + fun `Initial state has empty search query`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertEquals("", state.searchQuery.text) + } + + @Test + fun `Initial state sets visible item count to pageSize`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertEquals(10, state.itemsToDisplay) + } + + @Test + fun `Initial state has default filter values`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertEquals(LearnLearningLibraryStatusFilter.All, state.selectedStatusFilter) + assertEquals(LearnLearningLibraryTypeFilter.All, state.selectedTypeFilter) + } + + @Test + fun `Loading state shows error when repository fails`() = runTest { + coEvery { repository.getLearningLibraryItems(any(), any()) } throws Exception("Network error") + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertTrue(state.loadingState.isError) + assertFalse(state.loadingState.isLoading) + } + + @Test + fun `Empty collection loads successfully`() = runTest { + val emptyCollection = testCollection.copy(items = emptyList()) + coEvery { repository.getLearningLibraryItems(any(), any()) } returns emptyCollection + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertFalse(state.loadingState.isLoading) + assertFalse(state.loadingState.isError) + assertEquals(0, state.items.size) + } + + @Test + fun `Search query filters items by name case-insensitive`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("programming")) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals("Introduction to Programming", state.items[0].name) + } + + @Test + fun `Search query with partial match filters correctly`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("web")) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals("Web Development Guide", state.items[0].name) + } + + @Test + fun `Search query with no match returns empty list`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("NonExistentItem")) + + val state = viewModel.uiState.value + assertEquals(0, state.items.size) + } + + @Test + fun `Search query trims whitespace`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue(" algorithms ")) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals("Advanced Algorithms", state.items[0].name) + } + + @Test + fun `All status filter shows all items`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSelectedStatusFilter(LearnLearningLibraryStatusFilter.All) + + val state = viewModel.uiState.value + assertEquals(6, state.items.size) + } + + @Test + fun `Bookmarked status filter shows only bookmarked items`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSelectedStatusFilter(LearnLearningLibraryStatusFilter.Bookmarked) + + val state = viewModel.uiState.value + assertEquals(2, state.items.size) + assertTrue(state.items.all { it.isBookmarked }) + } + + @Test + fun `All type filter shows all items`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateTypeFilter(LearnLearningLibraryTypeFilter.All) + + val state = viewModel.uiState.value + assertEquals(6, state.items.size) + } + + @Test + fun `Assignments type filter shows only assignments`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateTypeFilter(LearnLearningLibraryTypeFilter.Assignments) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals(CollectionItemType.ASSIGNMENT, state.items[0].type) + } + + @Test + fun `Pages type filter shows only pages`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateTypeFilter(LearnLearningLibraryTypeFilter.Pages) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals(CollectionItemType.PAGE, state.items[0].type) + } + + @Test + fun `Files type filter shows only files`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateTypeFilter(LearnLearningLibraryTypeFilter.Files) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals(CollectionItemType.FILE, state.items[0].type) + } + + @Test + fun `ExternalLinks type filter shows only external URLs`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateTypeFilter(LearnLearningLibraryTypeFilter.ExternalLinks) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals(CollectionItemType.EXTERNAL_URL, state.items[0].type) + } + + @Test + fun `ExternalTools type filter shows only external tools`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateTypeFilter(LearnLearningLibraryTypeFilter.ExternalTools) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals(CollectionItemType.EXTERNAL_TOOL, state.items[0].type) + } + + @Test + fun `Assessments type filter returns empty list`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateTypeFilter(LearnLearningLibraryTypeFilter.Assessments) + + val state = viewModel.uiState.value + assertEquals(0, state.items.size) + } + + @Test + fun `Search and status filter work together`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("Advanced")) + viewModel.uiState.value.updateSelectedStatusFilter(LearnLearningLibraryStatusFilter.Bookmarked) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals("Advanced Algorithms", state.items[0].name) + assertTrue(state.items[0].isBookmarked) + } + + @Test + fun `Search and type filter work together`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("Development")) + viewModel.uiState.value.updateTypeFilter(LearnLearningLibraryTypeFilter.Files) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals("Web Development Guide", state.items[0].name) + assertEquals(CollectionItemType.FILE, state.items[0].type) + } + + @Test + fun `All three filters work together`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("External")) + viewModel.uiState.value.updateSelectedStatusFilter(LearnLearningLibraryStatusFilter.Completed) + viewModel.uiState.value.updateTypeFilter(LearnLearningLibraryTypeFilter.ExternalLinks) + + val state = viewModel.uiState.value + assertEquals(1, state.items.size) + assertEquals("External Learning Resource", state.items[0].name) + assertEquals(CollectionItemType.EXTERNAL_URL, state.items[0].type) + } + + @Test + fun `increaseItemsToDisplay increases count by pageSize`() = runTest { + val viewModel = getViewModel() + val initialCount = viewModel.uiState.value.itemsToDisplay + + viewModel.uiState.value.increaseItemsToDisplay() + + val state = viewModel.uiState.value + assertEquals(initialCount + 10, state.itemsToDisplay) + } + + @Test + fun `Multiple increaseItemsToDisplay calls accumulate`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.increaseItemsToDisplay() + viewModel.uiState.value.increaseItemsToDisplay() + + val state = viewModel.uiState.value + assertEquals(30, state.itemsToDisplay) + } + + @Test + fun `Pagination works with filtered items`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSelectedStatusFilter(LearnLearningLibraryStatusFilter.Bookmarked) + viewModel.uiState.value.increaseItemsToDisplay() + + val state = viewModel.uiState.value + assertEquals(20, state.itemsToDisplay) + assertEquals(2, state.items.size) + } + + @Test + fun `onBookmarkClicked sets loading state and updates bookmark to true`() = runTest { + val viewModel = getViewModel() + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item1") } returns true + + viewModel.uiState.value.onBookmarkClicked("item1") + + val state = viewModel.uiState.value + val item = state.items.find { it.id == "item1" } + assertNotNull(item) + assertTrue(item!!.isBookmarked) + assertFalse(item.bookmarkLoading) + coVerify { repository.toggleLearningLibraryItemIsBookmarked("item1") } + } + + @Test + fun `onBookmarkClicked updates bookmark to false`() = runTest { + val viewModel = getViewModel() + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item2") } returns false + + viewModel.uiState.value.onBookmarkClicked("item2") + + val state = viewModel.uiState.value + val item = state.items.find { it.id == "item2" } + assertNotNull(item) + assertFalse(item!!.isBookmarked) + assertFalse(item.bookmarkLoading) + } + + @Test + fun `onBookmarkClicked handles errors and shows error message`() = runTest { + val viewModel = getViewModel() + every { resources.getString(R.string.learnLearningLibraryFailedToUpdateBookmarkMessage) } returns "Failed to update bookmark" + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item1") } throws Exception("Network error") + + viewModel.uiState.value.onBookmarkClicked("item1") + + val state = viewModel.uiState.value + val item = state.items.find { it.id == "item1" } + assertNotNull(item) + assertFalse(item!!.bookmarkLoading) + assertNotNull(state.loadingState.snackbarMessage) + } + + @Test + fun `onBookmarkClicked updates only the correct item`() = runTest { + val viewModel = getViewModel() + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item1") } returns true + + val initialItem2Bookmark = viewModel.uiState.value.items.find { it.id == "item2" }!!.isBookmarked + + viewModel.uiState.value.onBookmarkClicked("item1") + + val state = viewModel.uiState.value + val item1 = state.items.find { it.id == "item1" } + val item2 = state.items.find { it.id == "item2" } + + assertTrue(item1!!.isBookmarked) + assertEquals(initialItem2Bookmark, item2!!.isBookmarked) + } + + @Test + fun `Bookmark state persists through filters`() = runTest { + val viewModel = getViewModel() + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item1") } returns true + + viewModel.uiState.value.onBookmarkClicked("item1") + viewModel.uiState.value.updateSelectedStatusFilter(LearnLearningLibraryStatusFilter.Bookmarked) + + val state = viewModel.uiState.value + val item = state.items.find { it.id == "item1" } + assertNotNull(item) + assertTrue(item!!.isBookmarked) + } + + @Test + fun `Refresh calls repository with forceRefresh true`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.loadingState.onRefresh() + + coVerify { repository.getLearningLibraryItems(testCollectionId, true) } + } + + @Test + fun `Refresh updates collection data`() = runTest { + val viewModel = getViewModel() + val updatedCollection = createTestCollection( + id = testCollectionId, + name = "Updated Collection Name", + items = listOf( + createTestCollectionItem( + id = "newItem", + courseName = "New Course", + courseId = "999", + completionPercentage = 0.0, + isBookmarked = false, + itemType = CollectionItemType.COURSE + ) + ) + ) + coEvery { repository.getLearningLibraryItems(testCollectionId, true) } returns updatedCollection + + viewModel.uiState.value.loadingState.onRefresh() + + val state = viewModel.uiState.value + assertFalse(state.loadingState.isRefreshing) + assertEquals("Updated Collection Name", state.collectionName) + assertEquals(1, state.items.size) + assertEquals("New Course", state.items[0].name) + } + + @Test + fun `Refresh on error shows snackbar message`() = runTest { + val viewModel = getViewModel() + every { resources.getString(R.string.learnLearningLibraryFailedToLoadCollectionMessage) } returns "Failed to load" + coEvery { repository.getLearningLibraryItems(testCollectionId, true) } throws Exception("Network error") + + viewModel.uiState.value.loadingState.onRefresh() + + val state = viewModel.uiState.value + assertFalse(state.loadingState.isRefreshing) + assertNotNull(state.loadingState.snackbarMessage) + } + + @Test + fun `Refresh maintains applied filters`() = runTest { + val viewModel = getViewModel() + coEvery { repository.getLearningLibraryItems(testCollectionId, true) } returns testCollection + + viewModel.uiState.value.updateSelectedStatusFilter(LearnLearningLibraryStatusFilter.Completed) + val itemCountBeforeRefresh = viewModel.uiState.value.items.size + + viewModel.uiState.value.loadingState.onRefresh() + + val state = viewModel.uiState.value + assertEquals(LearnLearningLibraryStatusFilter.Completed, state.selectedStatusFilter) + assertEquals(itemCountBeforeRefresh, state.items.size) + } + + @Test + fun `Dismiss snackbar clears snackbar message`() = runTest { + val viewModel = getViewModel() + every { resources.getString(R.string.learnLearningLibraryFailedToLoadCollectionMessage) } returns "Failed to load" + coEvery { repository.getLearningLibraryItems(testCollectionId, true) } throws Exception("Network error") + viewModel.uiState.value.loadingState.onRefresh() + + viewModel.uiState.value.loadingState.onSnackbarDismiss() + + val state = viewModel.uiState.value + assertNull(state.loadingState.snackbarMessage) + } + + @Test + fun `Collection name is mapped correctly`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertEquals("Software Engineering", state.collectionName) + } + + @Test + fun `Items are mapped correctly to UI state`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + val firstItem = state.items[0] + assertEquals("item1", firstItem.id) + assertEquals("Introduction to Programming", firstItem.name) + assertFalse(firstItem.isBookmarked) + assertEquals(CollectionItemType.COURSE, firstItem.type) + } + + private fun getViewModel(): LearnLearningLibraryDetailsViewModel { + return LearnLearningLibraryDetailsViewModel(savedStateHandle, resources, repository) + } + + private fun createTestCollection( + id: String, + name: String, + items: List + ): EnrolledLearningLibraryCollection = EnrolledLearningLibraryCollection( + id = id, + name = name, + publicName = name, + description = "Test description", + createdAt = Date(), + updatedAt = Date(), + items = items, + totalItemCount = items.size + ) + + private fun createTestCollectionItem( + id: String, + courseName: String, + courseId: String, + completionPercentage: Double, + isBookmarked: Boolean, + itemType: CollectionItemType, + isEnrolledInCanvas: Boolean = true + ): LearningLibraryCollectionItem = LearningLibraryCollectionItem( + id = id, + itemType = itemType, + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasCourse = CanvasCourseInfo( + courseId = courseId, + courseName = courseName, + courseImageUrl = "https://example.com/$courseId.png", + estimatedDurationMinutes = 60.0, + moduleCount = 5.0, + moduleItemCount = 10.0, + canvasUrl = "https://example.com", + ), + libraryId = "library1", + displayOrder = 1.0, + programId = null, + programCourseId = null, + createdAt = Date(), + updatedAt = Date(), + moduleInfo = null, + canvasEnrollmentId = null + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/enroll/LearnLearningLibraryEnrollRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/enroll/LearnLearningLibraryEnrollRepositoryTest.kt new file mode 100644 index 0000000000..1d17dc0184 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/enroll/LearnLearningLibraryEnrollRepositoryTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.learninglibrary.enroll + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DataResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.util.Date + +class LearnLearningLibraryEnrollRepositoryTest { + private val getLearningLibraryManager: GetLearningLibraryManager = mockk(relaxed = true) + private val getCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) + + private val testUser = User(id = 42L) + private val testCourse = CourseWithProgress( + courseId = 1L, + courseName = "Test Course", + courseImageUrl = "https://example.com/image.png", + courseSyllabus = "Course syllabus content", + progress = 0.0 + ) + private val testCollectionItem = createTestCollectionItem("item1", "1", "Test Course") + + @Before + fun setup() { + every { apiPrefs.user } returns testUser + coEvery { getLearningLibraryManager.getLearningLibraryItem(any(), any()) } returns testCollectionItem + coEvery { getCoursesManager.getCourseWithProgressById(any(), any()) } returns DataResult.Success(testCourse) + coEvery { getLearningLibraryManager.enrollLearningLibraryItem(any()) } returns testCollectionItem + } + + @Test + fun `loadLearningLibraryItem returns collection item`() = runTest { + val repository = getRepository() + + val result = repository.loadLearningLibraryItem("item1") + + assertEquals(testCollectionItem, result) + coVerify { getLearningLibraryManager.getLearningLibraryItem("item1", false) } + } + + @Test + fun `loadLearningLibraryItem passes forceNetwork false to manager`() = runTest { + val repository = getRepository() + + repository.loadLearningLibraryItem("item1") + + coVerify { getLearningLibraryManager.getLearningLibraryItem("item1", false) } + } + + @Test + fun `loadCourseDetails returns course with progress`() = runTest { + val repository = getRepository() + + val result = repository.loadCourseDetails(1L) + + assertEquals(testCourse, result) + } + + @Test + fun `loadCourseDetails passes courseId and userId to manager`() = runTest { + val repository = getRepository() + + repository.loadCourseDetails(1L) + + coVerify { getCoursesManager.getCourseWithProgressById(1L, 42L) } + } + + @Test + fun `loadCourseDetails uses -1L as userId when user is null`() = runTest { + every { apiPrefs.user } returns null + val repository = getRepository() + + repository.loadCourseDetails(1L) + + coVerify { getCoursesManager.getCourseWithProgressById(1L, -1L) } + } + + @Test + fun `loadCourseDetails throws when manager returns failure`() = runTest { + coEvery { getCoursesManager.getCourseWithProgressById(any(), any()) } returns DataResult.Fail() + val repository = getRepository() + + var threw = false + try { + repository.loadCourseDetails(1L) + } catch (e: Exception) { + threw = true + } + + assertEquals(true, threw) + } + + @Test + fun `enrollLearningLibraryItem returns enrolled item`() = runTest { + val enrolledItem = createTestCollectionItem("item1", "1", "Test Course Enrolled") + coEvery { getLearningLibraryManager.enrollLearningLibraryItem("item1") } returns enrolledItem + val repository = getRepository() + + val result = repository.enrollLearningLibraryItem("item1") + + assertEquals(enrolledItem, result) + coVerify { getLearningLibraryManager.enrollLearningLibraryItem("item1") } + } + + @Test + fun `enrollLearningLibraryItem passes itemId to manager`() = runTest { + val repository = getRepository() + + repository.enrollLearningLibraryItem("item-42") + + coVerify { getLearningLibraryManager.enrollLearningLibraryItem("item-42") } + } + + private fun getRepository(): LearnLearningLibraryEnrollRepository { + return LearnLearningLibraryEnrollRepository(getLearningLibraryManager, getCoursesManager, apiPrefs) + } + + private fun createTestCollectionItem( + id: String, + courseId: String, + courseName: String + ): LearningLibraryCollectionItem = LearningLibraryCollectionItem( + id = id, + libraryId = "library1", + itemType = CollectionItemType.COURSE, + displayOrder = 1.0, + canvasCourse = CanvasCourseInfo( + courseId = courseId, + canvasUrl = "https://example.com", + courseName = courseName, + courseImageUrl = "https://example.com/image.png", + moduleCount = 5.0, + moduleItemCount = 20.0, + estimatedDurationMinutes = 120.0 + ), + programId = null, + programCourseId = null, + createdAt = Date(), + updatedAt = Date(), + isBookmarked = false, + completionPercentage = 0.0, + isEnrolledInCanvas = false, + moduleInfo = null, + canvasEnrollmentId = null + ) +} \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/enroll/LearnLearningLibraryEnrollViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/enroll/LearnLearningLibraryEnrollViewModelTest.kt new file mode 100644 index 0000000000..54e22d0df3 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/enroll/LearnLearningLibraryEnrollViewModelTest.kt @@ -0,0 +1,284 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.learninglibrary.enroll + +import android.content.res.Resources +import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem +import com.instructure.horizon.R +import com.instructure.horizon.features.learn.LearnEvent +import com.instructure.horizon.features.learn.LearnEventHandler +import com.instructure.horizon.features.learn.navigation.LearnRoute +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class LearnLearningLibraryEnrollViewModelTest { + private val resources: Resources = mockk(relaxed = true) + private val repository: LearnLearningLibraryEnrollRepository = mockk(relaxed = true) + private val eventHandler: LearnEventHandler = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + private val testItemId = "item-123" + private val testCourseId = "456" + private val testSyllabus = "This is the course syllabus" + private val testCollectionItem = createTestCollectionItem(testItemId, testCourseId, "Test Course") + private val testCourse = CourseWithProgress( + courseId = testCourseId.toLong(), + courseName = "Test Course", + courseImageUrl = null, + courseSyllabus = testSyllabus, + progress = 0.0 + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + every { resources.getString(any()) } returns "" + coEvery { repository.loadLearningLibraryItem(any()) } returns testCollectionItem + coEvery { repository.loadCourseDetails(any()) } returns testCourse + coEvery { repository.enrollLearningLibraryItem(any()) } returns testCollectionItem + coEvery { eventHandler.postEvent(any()) } returns Unit + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Initial state has pull to refresh disabled`() = runTest { + val viewModel = getViewModel() + + assertFalse(viewModel.state.value.loadingState.isPullToRefreshEnabled) + } + + @Test + fun `Initial state has no syllabus`() = runTest { + val viewModel = getViewModel(itemId = null) + + assertNull(viewModel.state.value.syllabus) + } + + @Test + fun `Initial state has isEnrollLoading false`() = runTest { + val viewModel = getViewModel(itemId = null) + + assertFalse(viewModel.state.value.isEnrollLoading) + } + + @Test + fun `Initial state has no navigateToCourseId`() = runTest { + val viewModel = getViewModel(itemId = null) + + assertNull(viewModel.state.value.navigateToCourseId) + } + + @Test + fun `loadData is triggered on init when ID is in SavedStateHandle`() = runTest { + getViewModel() + + coVerify { repository.loadLearningLibraryItem(testItemId) } + } + + @Test + fun `loadData is not triggered on init when no ID in SavedStateHandle`() = runTest { + getViewModel(itemId = null) + + coVerify(exactly = 0) { repository.loadLearningLibraryItem(any()) } + } + + @Test + fun `loadData success sets syllabus from course details`() = runTest { + val viewModel = getViewModel() + + assertEquals(testSyllabus, viewModel.state.value.syllabus) + } + + @Test + fun `loadData success clears loading state`() = runTest { + val viewModel = getViewModel() + + assertFalse(viewModel.state.value.loadingState.isLoading) + } + + @Test + fun `loadData calls loadCourseDetails with courseId from collection item`() = runTest { + getViewModel() + + coVerify { repository.loadCourseDetails(testCourseId.toLong()) } + } + + @Test + fun `loadData error clears loading state`() = runTest { + coEvery { repository.loadLearningLibraryItem(any()) } throws Exception("Network error") + val viewModel = getViewModel() + + assertFalse(viewModel.state.value.loadingState.isLoading) + } + + @Test + fun `loadData error does not set error state`() = runTest { + coEvery { repository.loadLearningLibraryItem(any()) } throws Exception("Network error") + val viewModel = getViewModel() + + assertFalse(viewModel.state.value.loadingState.isError) + } + + @Test + fun `loadData can be called manually with a new item id`() = runTest { + val viewModel = getViewModel(itemId = null) + + viewModel.loadData("other-item") + + coVerify { repository.loadLearningLibraryItem("other-item") } + } + + @Test + fun `onEnroll success sets navigateToCourseId`() = runTest { + val viewModel = getViewModel() + + viewModel.state.value.onEnrollClicked() + + assertEquals(testCourseId.toLong(), viewModel.state.value.navigateToCourseId) + } + + @Test + fun `onEnroll success clears isEnrollLoading`() = runTest { + val viewModel = getViewModel() + + viewModel.state.value.onEnrollClicked() + + assertFalse(viewModel.state.value.isEnrollLoading) + } + + @Test + fun `onEnroll success posts RefreshLearningLibraryList event`() = runTest { + val viewModel = getViewModel() + + viewModel.state.value.onEnrollClicked() + + coVerify { eventHandler.postEvent(LearnEvent.RefreshLearningLibraryList) } + } + + @Test + fun `onEnroll calls repository with item id`() = runTest { + val viewModel = getViewModel() + + viewModel.state.value.onEnrollClicked() + + coVerify { repository.enrollLearningLibraryItem(testItemId) } + } + + @Test + fun `onEnroll error sets error message`() = runTest { + every { resources.getString(R.string.learnLearningLibraryEnrollDialogFailedToEnrollMessage) } returns "Failed to enroll" + coEvery { repository.enrollLearningLibraryItem(any()) } throws Exception("Network error") + val viewModel = getViewModel() + + viewModel.state.value.onEnrollClicked() + + assertNotNull(viewModel.state.value.loadingState.snackbarMessage) + } + + @Test + fun `onEnroll error clears isEnrollLoading`() = runTest { + coEvery { repository.enrollLearningLibraryItem(any()) } throws Exception("Network error") + val viewModel = getViewModel() + + viewModel.state.value.onEnrollClicked() + + assertFalse(viewModel.state.value.isEnrollLoading) + } + + @Test + fun `onEnroll error does not set navigateToCourseId`() = runTest { + coEvery { repository.enrollLearningLibraryItem(any()) } throws Exception("Network error") + val viewModel = getViewModel() + + viewModel.state.value.onEnrollClicked() + + assertNull(viewModel.state.value.navigateToCourseId) + } + + @Test + fun `resetNavigateToCourseId clears navigateToCourseId`() = runTest { + val viewModel = getViewModel() + viewModel.state.value.onEnrollClicked() + + viewModel.state.value.resetNavigateToCourseId() + + assertNull(viewModel.state.value.navigateToCourseId) + } + + private fun getViewModel(itemId: String? = testItemId): LearnLearningLibraryEnrollViewModel { + val savedStateHandle = if (itemId != null) { + SavedStateHandle(mapOf(LearnRoute.LearnLearningLibraryEnrollScreen.learningLibraryIdAttr to itemId)) + } else { + SavedStateHandle() + } + return LearnLearningLibraryEnrollViewModel(savedStateHandle, resources, repository, eventHandler) + } + + private fun createTestCollectionItem( + id: String, + courseId: String, + courseName: String + ): LearningLibraryCollectionItem = LearningLibraryCollectionItem( + id = id, + libraryId = "library1", + itemType = CollectionItemType.COURSE, + displayOrder = 1.0, + canvasCourse = CanvasCourseInfo( + courseId = courseId, + canvasUrl = "https://example.com", + courseName = courseName, + courseImageUrl = "https://example.com/image.png", + moduleCount = 5.0, + moduleItemCount = 20.0, + estimatedDurationMinutes = 120.0 + ), + programId = null, + programCourseId = null, + createdAt = Date(), + updatedAt = Date(), + isBookmarked = false, + completionPercentage = 0.0, + isEnrolledInCanvas = false, + moduleInfo = null, + canvasEnrollmentId = null + ) +} \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt new file mode 100644 index 0000000000..627029aee7 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListRepositoryTest.kt @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.learninglibrary.list + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetLearningLibraryManager +import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollectionsResponse +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.util.Date + +class LearnLearningLibraryListRepositoryTest { + private val getLearningLibraryManager: GetLearningLibraryManager = mockk(relaxed = true) + + private val testCollections = listOf( + EnrolledLearningLibraryCollection( + id = "collection1", + name = "Software Engineering", + totalItemCount = 2, + items = listOf( + createTestCollectionItem( + id = "item1", + courseName = "Intro to Programming", + courseId = "1", + isBookmarked = false, + itemType = CollectionItemType.COURSE + ), + createTestCollectionItem( + id = "item2", + courseName = "Advanced Algorithms", + courseId = "2", + isBookmarked = true, + itemType = CollectionItemType.PAGE + ) + ), + publicName = "", + description = "", + createdAt = Date(), + updatedAt = Date() + ), + EnrolledLearningLibraryCollection( + id = "collection2", + name = "Data Science", + totalItemCount = 1, + items = listOf( + createTestCollectionItem( + id = "item3", + courseName = "Machine Learning", + courseId = "3", + isBookmarked = false, + itemType = CollectionItemType.COURSE + ) + ), + publicName = "", + description = "", + createdAt = Date(), + updatedAt = Date() + ) + ) + + private val emptyPageInfo = LearningLibraryPageInfo( + nextCursor = null, + previousCursor = null, + hasNextPage = false, + hasPreviousPage = false, + totalCount = 10, + pageCursors = null + ) + + @Before + fun setup() { + val response = EnrolledLearningLibraryCollectionsResponse( + collections = testCollections + ) + coEvery { getLearningLibraryManager.getEnrolledLearningLibraryCollections(any(), any()) } returns response + coEvery { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = emptyList(), + pageInfo = emptyPageInfo + ) + } + + @Test + fun `getEnrolledLearningLibraries returns list of collections`() = runTest { + val repository = getRepository() + val result = repository.getEnrolledLearningLibraries(false) + + assertEquals(2, result.size) + assertEquals(testCollections, result) + coVerify { getLearningLibraryManager.getEnrolledLearningLibraryCollections(4, false) } + } + + @Test + fun `getEnrolledLearningLibraries with forceNetwork true calls API with force network`() = runTest { + val repository = getRepository() + repository.getEnrolledLearningLibraries(true) + + coVerify { getLearningLibraryManager.getEnrolledLearningLibraryCollections(4, true) } + } + + @Test + fun `getEnrolledLearningLibraries returns empty list when no collections`() = runTest { + val emptyResponse = EnrolledLearningLibraryCollectionsResponse(collections = emptyList()) + coEvery { getLearningLibraryManager.getEnrolledLearningLibraryCollections(any(), any()) } returns emptyResponse + val repository = getRepository() + val result = repository.getEnrolledLearningLibraries(false) + + assertEquals(0, result.size) + } + + @Test + fun `getLearningLibraryItems returns items with no filters`() = runTest { + val items = listOf(createTestCollectionItem("item1", "Python", "1", false, CollectionItemType.COURSE)) + coEvery { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = items, + pageInfo = emptyPageInfo + ) + val repository = getRepository() + + val result = repository.getLearningLibraryItems(forceNetwork = false) + + assertEquals(1, result.items.size) + assertEquals(items, result.items) + } + + @Test + fun `getLearningLibraryItems with cursor passes cursor to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(afterCursor = "cursor123", forceNetwork = false) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(cursor = "cursor123", any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `getLearningLibraryItems with searchQuery passes searchTerm to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(searchQuery = "python", forceNetwork = false) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), searchTerm = "python", any(), any(), any()) } + } + + @Test + fun `getLearningLibraryItems with typeFilter passes types to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(typeFilter = CollectionItemType.COURSE, forceNetwork = false) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), types = listOf(CollectionItemType.COURSE), any(), any()) } + } + + @Test + fun `getLearningLibraryItems with null typeFilter passes null types to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(typeFilter = null, forceNetwork = false) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), types = null, any(), any()) } + } + + @Test + fun `getLearningLibraryItems with bookmarkedOnly passes bookmarkedOnly to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(bookmarkedOnly = true, forceNetwork = false) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), bookmarkedOnly = true, any(), any(), any(), any()) } + } + + @Test + fun `getLearningLibraryItems with completedOnly passes completedOnly to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(completedOnly = true, forceNetwork = false) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), completedOnly = true, any()) } + } + + @Test + fun `getLearningLibraryItems with forceNetwork true passes flag to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(forceNetwork = true) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), forceNetwork = true) } + } + + @Test + fun `getLearningLibraryItems returns pagination info`() = runTest { + val pageInfo = LearningLibraryPageInfo( + nextCursor = "next_cursor", + previousCursor = null, + hasNextPage = true, + hasPreviousPage = false, + totalCount = 10, + pageCursors = null + ) + coEvery { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = emptyList(), + pageInfo = pageInfo + ) + val repository = getRepository() + + val result = repository.getLearningLibraryItems(forceNetwork = false) + + assertTrue(result.pageInfo.hasNextPage) + assertEquals("next_cursor", result.pageInfo.nextCursor) + } + + @Test + fun `getLearningLibraryItems with limit passes limit to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(limit = 5, forceNetwork = false) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), limit = 5, any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `getLearningLibraryItems with sortBy passes sortBy to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(sortBy = CollectionItemSortOption.NAME_A_Z, forceNetwork = false) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), sortBy = CollectionItemSortOption.NAME_A_Z, any()) } + } + + @Test + fun `getLearningLibraryItems with null sortBy passes null sortBy to manager`() = runTest { + val repository = getRepository() + + repository.getLearningLibraryItems(sortBy = null, forceNetwork = false) + + coVerify { getLearningLibraryManager.getLearningLibraryCollectionItems(any(), any(), any(), any(), any(), any(), any(), sortBy = null, any()) } + } + + @Test + fun `toggleLearningLibraryItemIsBookmarked returns new bookmark state`() = runTest { + coEvery { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item1") } returns true + val repository = getRepository() + val result = repository.toggleLearningLibraryItemIsBookmarked("item1") + + assertTrue(result) + coVerify { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item1") } + } + + @Test + fun `toggleLearningLibraryItemIsBookmarked returns false when unbookmarking`() = runTest { + coEvery { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item2") } returns false + val repository = getRepository() + val result = repository.toggleLearningLibraryItemIsBookmarked("item2") + + assertFalse(result) + coVerify { getLearningLibraryManager.toggleLearningLibraryItemIsBookmarked("item2") } + } + + private fun getRepository(): LearnLearningLibraryListRepository { + return LearnLearningLibraryListRepository(getLearningLibraryManager) + } + + private fun createTestCollectionItem( + id: String, + courseName: String, + courseId: String, + isBookmarked: Boolean, + itemType: CollectionItemType, + isEnrolledInCanvas: Boolean = false + ): LearningLibraryCollectionItem = LearningLibraryCollectionItem( + id = id, + itemType = itemType, + isBookmarked = isBookmarked, + completionPercentage = 0.0, + isEnrolledInCanvas = isEnrolledInCanvas, + canvasCourse = CanvasCourseInfo( + courseId = courseId, + courseName = courseName, + courseImageUrl = "https://example.com/$courseId.png", + estimatedDurationMinutes = 60.0, + moduleCount = 5.0, + moduleItemCount = 5.0, + canvasUrl = "", + ), + libraryId = "", + displayOrder = 1.0, + programId = "", + programCourseId = "", + createdAt = Date(), + updatedAt = Date(), + moduleInfo = null, + canvasEnrollmentId = null + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt new file mode 100644 index 0000000000..9ac3e76f58 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/learninglibrary/list/LearnLearningLibraryListViewModelTest.kt @@ -0,0 +1,848 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.learninglibrary.list + +import android.content.res.Resources +import androidx.compose.ui.text.input.TextFieldValue +import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.EnrolledLearningLibraryCollection +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.horizon.R +import com.instructure.horizon.features.learn.LearnEvent +import com.instructure.horizon.features.learn.LearnEventHandler +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryFilterScreenType +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter +import com.instructure.pandautils.utils.ThemePrefs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class LearnLearningLibraryListViewModelTest { + private val eventHandler = LearnEventHandler() + private val resources: Resources = mockk(relaxed = true) + private val repository: LearnLearningLibraryListRepository = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + private val emptyItemsResponse = LearningLibraryCollectionItemsResponse( + items = emptyList(), + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + + private var testCollections: List = listOf( + createTestCollection( + id = "collection1", + name = "Introduction to Programming", + items = listOf( + createTestCollectionItem( + id = "item1", + courseId = "1", + courseName = "Python Basics", + completionPercentage = 0.0, + isBookmarked = false, + isEnrolledInCanvas = true + ) + ) + ), + createTestCollection( + id = "collection2", + name = "Advanced Web Development", + items = listOf( + createTestCollectionItem( + id = "item2", + courseId = "2", + courseName = "React Advanced", + completionPercentage = 50.0, + isBookmarked = true, + isEnrolledInCanvas = true + ) + ) + ), + createTestCollection( + id = "collection3", + name = "Data Science Fundamentals", + items = listOf( + createTestCollectionItem( + id = "item3", + courseId = "3", + courseName = "Machine Learning", + completionPercentage = 100.0, + isBookmarked = false, + isEnrolledInCanvas = true + ) + ) + ) + ) + + @Before + fun setup() { + mockkObject(ThemePrefs) + every { ThemePrefs.brandColor } returns 0xFF0000FF.toInt() + every { ThemePrefs.mobileLogoUrl } returns "https://example.com/logo.png" + Dispatchers.setMain(testDispatcher) + every { resources.getString(any()) } returns "" + every { resources.getString(any(), *anyVararg()) } returns "" + every { resources.getQuantityString(any(), any()) } returns "2 items" + every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "2 items" + + coEvery { repository.getEnrolledLearningLibraries(any()) } returns testCollections + coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns emptyItemsResponse + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Initial state loads collections successfully`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertFalse(state.collectionState.loadingState.isLoading) + assertFalse(state.collectionState.loadingState.isError) + assertEquals(3, state.collectionState.collections.size) + coVerify { repository.getEnrolledLearningLibraries(false) } + } + + @Test + fun `Initial state has empty search query`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertEquals("", state.searchQuery.text) + } + + @Test + fun `Initial state sets visible item count to pageSize`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertEquals(3, state.collectionState.itemsToDisplay) + } + + @Test + fun `Initial state has All type filter`() = runTest { + val viewModel = getViewModel() + + assertEquals(LearnLearningLibraryTypeFilter.All, viewModel.uiState.value.typeFilter) + } + + @Test + fun `Initial state has zero active filter count`() = runTest { + val viewModel = getViewModel() + + assertEquals(0, viewModel.uiState.value.activeFilterCount) + } + + @Test + fun `Initial state has MostRecent sort option`() = runTest { + val viewModel = getViewModel() + + assertEquals(LearnLearningLibrarySortOption.MostRecent, viewModel.uiState.value.sortOption) + } + + @Test + fun `Loading state shows error when repository fails`() = runTest { + coEvery { repository.getEnrolledLearningLibraries(any()) } throws Exception("Network error") + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertTrue(state.collectionState.loadingState.isError) + assertFalse(state.collectionState.loadingState.isLoading) + } + + @Test + fun `Empty collections list loads successfully`() = runTest { + coEvery { repository.getEnrolledLearningLibraries(any()) } returns emptyList() + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertFalse(state.collectionState.loadingState.isLoading) + assertFalse(state.collectionState.loadingState.isError) + assertEquals(0, state.collectionState.collections.size) + } + + @Test + fun `increaseItemsToDisplay increases count by pageSize`() = runTest { + val viewModel = getViewModel() + val initialCount = viewModel.uiState.value.collectionState.itemsToDisplay + + viewModel.uiState.value.collectionState.increaseItemsToDisplay() + + val state = viewModel.uiState.value + assertEquals(initialCount + 3, state.collectionState.itemsToDisplay) + } + + @Test + fun `Multiple increaseItemsToDisplay calls accumulate`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.collectionState.increaseItemsToDisplay() + viewModel.uiState.value.collectionState.increaseItemsToDisplay() + + val state = viewModel.uiState.value + assertEquals(9, state.collectionState.itemsToDisplay) + } + + @Test + fun `Collections are mapped correctly to LearnLearningLibraryCollectionState`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + val firstCollection = state.collectionState.collections[0] + assertEquals("collection1", firstCollection.id) + assertEquals("Introduction to Programming", firstCollection.name) + assertEquals(1, firstCollection.itemCount) + assertEquals(1, firstCollection.items.size) + } + + @Test + fun `Collection items are mapped correctly`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + val firstItem = state.collectionState.collections[0].items[0] + assertEquals("item1", firstItem.id) + assertEquals("Python Basics", firstItem.name) + assertFalse(firstItem.isBookmarked) + } + + @Test + fun `Course item not enrolled in Canvas can enroll`() = runTest { + val collections = listOf( + createTestCollection( + id = "collection1", + name = "Test Collection", + items = listOf( + createTestCollectionItem( + id = "item1", + courseId = "1", + courseName = "Test Course", + itemType = CollectionItemType.COURSE, + isEnrolledInCanvas = false + ) + ) + ) + ) + coEvery { repository.getEnrolledLearningLibraries(any()) } returns collections + val viewModel = getViewModel() + + val state = viewModel.uiState.value + val firstItem = state.collectionState.collections[0].items[0] + assertTrue(firstItem.canEnroll) + } + + @Test + fun `Course item already enrolled in Canvas cannot enroll`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + val firstItem = state.collectionState.collections[0].items[0] + assertFalse(firstItem.canEnroll) + } + + @Test + fun `Non-course item cannot enroll`() = runTest { + val collections = listOf( + createTestCollection( + id = "collection1", + name = "Test Collection", + items = listOf( + createTestCollectionItem( + id = "item1", + courseId = "1", + courseName = "Test Page", + itemType = CollectionItemType.PAGE, + isEnrolledInCanvas = false + ) + ) + ) + ) + coEvery { repository.getEnrolledLearningLibraries(any()) } returns collections + val viewModel = getViewModel() + + val state = viewModel.uiState.value + val firstItem = state.collectionState.collections[0].items[0] + assertFalse(firstItem.canEnroll) + } + + @Test + fun `Refresh calls repository with forceNetwork true`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.collectionState.loadingState.onRefresh() + + coVerify { repository.getEnrolledLearningLibraries(true) } + } + + @Test + fun `Refresh updates collections list`() = runTest { + val viewModel = getViewModel() + val updatedCollections = listOf( + createTestCollection( + id = "collection4", + name = "New Collection", + items = listOf( + createTestCollectionItem( + id = "item4", + courseId = "4", + courseName = "New Course", + completionPercentage = 0.0, + isBookmarked = false, + isEnrolledInCanvas = true + ) + ) + ) + ) + coEvery { repository.getEnrolledLearningLibraries(true) } returns updatedCollections + + viewModel.uiState.value.collectionState.loadingState.onRefresh() + + val state = viewModel.uiState.value + assertFalse(state.collectionState.loadingState.isRefreshing) + assertEquals(1, state.collectionState.collections.size) + assertEquals("New Collection", state.collectionState.collections[0].name) + } + + @Test + fun `Refresh on error shows snackbar message`() = runTest { + val viewModel = getViewModel() + coEvery { repository.getEnrolledLearningLibraries(true) } throws Exception("Network error") + + viewModel.uiState.value.collectionState.loadingState.onRefresh() + + val state = viewModel.uiState.value + assertFalse(state.collectionState.loadingState.isRefreshing) + assertTrue(state.collectionState.loadingState.snackbarMessage != null) + } + + @Test + fun `Dismiss snackbar clears both collection and item snackbar messages`() = runTest { + val viewModel = getViewModel() + coEvery { repository.getEnrolledLearningLibraries(true) } throws Exception("Network error") + viewModel.uiState.value.collectionState.loadingState.onRefresh() + + viewModel.uiState.value.collectionState.loadingState.onSnackbarDismiss() + + val state = viewModel.uiState.value + assertNull(state.collectionState.loadingState.snackbarMessage) + assertNull(state.itemState.loadingState.snackbarMessage) + } + + @Test + fun `onCollectionBookmarkClicked sets loading state and updates bookmark`() = runTest { + val viewModel = getViewModel() + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item1") } returns true + + viewModel.uiState.value.collectionState.onBookmarkClicked("item1") + + val state = viewModel.uiState.value + val firstItem = state.collectionState.collections[0].items[0] + assertTrue(firstItem.isBookmarked) + assertFalse(firstItem.bookmarkLoading) + coVerify { repository.toggleLearningLibraryItemIsBookmarked("item1") } + } + + @Test + fun `onCollectionBookmarkClicked handles errors and shows error message`() = runTest { + val viewModel = getViewModel() + every { resources.getString(R.string.learnLearningLibraryFailedToUpdateBookmarkMessage) } returns "Failed to update bookmark" + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item1") } throws Exception("Network error") + + viewModel.uiState.value.collectionState.onBookmarkClicked("item1") + + val state = viewModel.uiState.value + val firstItem = state.collectionState.collections[0].items[0] + assertFalse(firstItem.bookmarkLoading) + assertTrue(state.collectionState.loadingState.snackbarMessage != null) + } + + @Test + fun `isEmptyFilter returns true when no filters are applied`() = runTest { + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.isEmptyFilter()) + } + + @Test + fun `isEmptyFilter returns false when search query is set`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("python")) + + assertFalse(viewModel.uiState.value.isEmptyFilter()) + } + + @Test + fun `isEmptyFilter returns false when activeFilterCount is non-zero`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + assertFalse(viewModel.uiState.value.isEmptyFilter()) + } + + @Test + fun `UpdateLearningLibraryFilter event updates typeFilter in state`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Courses, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + assertEquals(LearnLearningLibraryTypeFilter.Courses, viewModel.uiState.value.typeFilter) + } + + @Test + fun `UpdateLearningLibraryFilter event updates sortOption in state`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.All, + sortOption = LearnLearningLibrarySortOption.NameAscending + )) + + assertEquals(LearnLearningLibrarySortOption.NameAscending, viewModel.uiState.value.sortOption) + } + + @Test + fun `UpdateLearningLibraryFilter event increments activeFilterCount when typeFilter is non-All`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + assertEquals(1, viewModel.uiState.value.activeFilterCount) + } + + @Test + fun `UpdateLearningLibraryFilter event resets activeFilterCount to zero when typeFilter is All`() = runTest { + val viewModel = getViewModel() + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.All, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + assertEquals(0, viewModel.uiState.value.activeFilterCount) + } + + @Test + fun `UpdateLearningLibraryFilter event with MyContent screenType is ignored by Browse VM`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + assertEquals(LearnLearningLibraryTypeFilter.All, viewModel.uiState.value.typeFilter) + assertEquals(0, viewModel.uiState.value.activeFilterCount) + } + + @Test + fun `UpdateLearningLibraryFilter event with MyContentSaved screenType is ignored by Browse VM`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContentSaved, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + assertEquals(LearnLearningLibraryTypeFilter.All, viewModel.uiState.value.typeFilter) + assertEquals(0, viewModel.uiState.value.activeFilterCount) + } + + @Test + fun `UpdateLearningLibraryFilter event passes typeFilter to repository`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + coVerify { repository.getLearningLibraryItems(any(), any(), any(), typeFilter = CollectionItemType.PAGE, any(), any(), any(), any()) } + } + + @Test + fun `UpdateLearningLibraryFilter event passes sortOption to repository`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.All, + sortOption = LearnLearningLibrarySortOption.NameAscending + )) + + coVerify { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), sortBy = CollectionItemSortOption.NAME_A_Z, any()) } + } + + @Test + fun `Item loading populates items from repository`() = runTest { + val testItems = listOf( + createTestCollectionItem(id = "item1", courseId = "1", courseName = "Python Basics") + ) + coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = testItems, + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + val state = viewModel.uiState.value + assertEquals(1, state.itemState.items.size) + assertEquals("Python Basics", state.itemState.items[0].name) + } + + @Test + fun `Item loading shows error state when repository fails`() = runTest { + val viewModel = getViewModel() + coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + val state = viewModel.uiState.value + assertTrue(state.itemState.loadingState.isError) + } + + @Test + fun `Search query triggers item loading after debounce`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("python")) + advanceTimeBy(350) + + coVerify { repository.getLearningLibraryItems(null, any(), "python", any(), any(), any(), any(), any()) } + } + + @Test + fun `Search query updates searchQuery in state immediately`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("python")) + + assertEquals("python", viewModel.uiState.value.searchQuery.text) + } + + @Test + fun `Search query change replaces item list when no cursor`() = runTest { + val firstResponse = LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item1", courseName = "Python Basics")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns firstResponse + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + val secondResponse = LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item2", courseName = "React Advanced")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns secondResponse + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Courses, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + assertEquals(1, viewModel.uiState.value.itemState.items.size) + assertEquals("React Advanced", viewModel.uiState.value.itemState.items[0].name) + } + + @Test + fun `showMoreButton is set when API has next page`() = runTest { + coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item1")), + pageInfo = LearningLibraryPageInfo(nextCursor = "cursor1", previousCursor = null, hasNextPage = true, hasPreviousPage = false, totalCount = null, pageCursors = null) + ) + val viewModel = getViewModel() + + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + assertTrue(viewModel.uiState.value.itemState.showMoreButton) + } + + @Test + fun `onShowMoreClicked fetches next page and appends items`() = runTest { + val firstPage = LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item1", courseName = "First Course")), + pageInfo = LearningLibraryPageInfo(nextCursor = "cursor1", previousCursor = null, hasNextPage = true, hasPreviousPage = false, totalCount = null, pageCursors = null) + ) + val secondPage = LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item2", courseName = "Second Course")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), any()) } returns firstPage + coEvery { repository.getLearningLibraryItems("cursor1", any(), any(), any(), any(), any(), any(), any()) } returns secondPage + val viewModel = getViewModel() + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + viewModel.uiState.value.itemState.onShowMoreClicked() + + val items = viewModel.uiState.value.itemState.items + assertEquals(2, items.size) + assertEquals("First Course", items[0].name) + assertEquals("Second Course", items[1].name) + } + + @Test + fun `onShowMoreClicked sets isMoreButtonLoading during load`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.itemState.onShowMoreClicked() + + assertFalse(viewModel.uiState.value.itemState.isMoreButtonLoading) + } + + @Test + fun `Items refresh calls repository with forceNetwork true`() = runTest { + val viewModel = getViewModel() + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + viewModel.uiState.value.itemState.loadingState.onRefresh() + + coVerify { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), true) } + } + + @Test + fun `Items refresh updates items list`() = runTest { + val viewModel = getViewModel() + val refreshedItems = listOf( + createTestCollectionItem(id = "item1", courseName = "Refreshed Course") + ) + coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), true) } returns LearningLibraryCollectionItemsResponse( + items = refreshedItems, + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + + viewModel.uiState.value.itemState.loadingState.onRefresh() + + val state = viewModel.uiState.value + assertFalse(state.itemState.loadingState.isRefreshing) + assertEquals(1, state.itemState.items.size) + assertEquals("Refreshed Course", state.itemState.items[0].name) + } + + @Test + fun `Items refresh on error shows snackbar message`() = runTest { + val viewModel = getViewModel() + coEvery { repository.getLearningLibraryItems(null, any(), any(), any(), any(), any(), any(), true) } throws Exception("Network error") + + viewModel.uiState.value.itemState.loadingState.onRefresh() + + val state = viewModel.uiState.value + assertFalse(state.itemState.loadingState.isRefreshing) + assertTrue(state.itemState.loadingState.snackbarMessage != null) + } + + @Test + fun `onItemBookmarkClicked updates bookmark in item state`() = runTest { + val testItems = listOf( + createTestCollectionItem(id = "item1", courseName = "Python Basics", isBookmarked = false) + ) + coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = testItems, + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item1") } returns true + val viewModel = getViewModel() + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + viewModel.uiState.value.itemState.onBookmarkClicked("item1") + + val updatedItem = viewModel.uiState.value.itemState.items.find { it.id == "item1" } + assertTrue(updatedItem!!.isBookmarked) + assertFalse(updatedItem.bookmarkLoading) + } + + @Test + fun `onItemBookmarkClicked also updates collection state bookmark`() = runTest { + val testItems = listOf( + createTestCollectionItem(id = "item1", courseName = "Python Basics", isBookmarked = false) + ) + coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = testItems, + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item1") } returns true + val viewModel = getViewModel() + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + viewModel.uiState.value.itemState.onBookmarkClicked("item1") + + val collectionItem = viewModel.uiState.value.collectionState.collections + .flatMap { it.items } + .find { it.id == "item1" } + assertTrue(collectionItem!!.isBookmarked) + } + + @Test + fun `onItemBookmarkClicked error shows error in item state`() = runTest { + val testItems = listOf( + createTestCollectionItem(id = "item1", courseName = "Python Basics", isBookmarked = false) + ) + coEvery { repository.getLearningLibraryItems(any(), any(), any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = testItems, + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + every { resources.getString(R.string.learnLearningLibraryFailedToUpdateBookmarkMessage) } returns "Failed to update bookmark" + coEvery { repository.toggleLearningLibraryItemIsBookmarked("item1") } throws Exception("Network error") + val viewModel = getViewModel() + eventHandler.postEvent(LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Pages, + sortOption = LearnLearningLibrarySortOption.MostRecent + )) + + viewModel.uiState.value.itemState.onBookmarkClicked("item1") + + val state = viewModel.uiState.value + assertFalse(state.itemState.items.find { it.id == "item1" }!!.bookmarkLoading) + assertTrue(state.itemState.loadingState.snackbarMessage != null) + } + + private fun getViewModel(): LearnLearningLibraryListViewModel { + return LearnLearningLibraryListViewModel(resources, repository, eventHandler, apiPrefs) + } + + private fun createTestCollection( + id: String = "testCollection", + name: String = "Test Collection", + items: List = emptyList() + ): EnrolledLearningLibraryCollection = EnrolledLearningLibraryCollection( + id = id, + name = name, + publicName = name, + description = "Test description", + createdAt = Date(), + updatedAt = Date(), + items = items, + totalItemCount = items.size + ) + + private fun createTestCollectionItem( + id: String = "testItem", + courseId: String = "1", + courseName: String = "Test Course", + completionPercentage: Double? = 0.0, + isBookmarked: Boolean = false, + isEnrolledInCanvas: Boolean? = true, + itemType: CollectionItemType = CollectionItemType.COURSE + ): LearningLibraryCollectionItem = LearningLibraryCollectionItem( + id = id, + libraryId = "library1", + itemType = itemType, + displayOrder = 1.0, + canvasCourse = CanvasCourseInfo( + courseId = courseId, + canvasUrl = "https://example.com", + courseName = courseName, + courseImageUrl = "https://example.com/image.png", + moduleCount = 5.0, + moduleItemCount = 20.0, + estimatedDurationMinutes = 120.0 + ), + programId = null, + programCourseId = null, + createdAt = Date(), + updatedAt = Date(), + isBookmarked = isBookmarked, + completionPercentage = completionPercentage, + isEnrolledInCanvas = isEnrolledInCanvas, + moduleInfo = null, + canvasEnrollmentId = null + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/LearnMyContentViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/LearnMyContentViewModelTest.kt new file mode 100644 index 0000000000..1ada75b9ae --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/LearnMyContentViewModelTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.mycontent + +import androidx.compose.ui.text.input.TextFieldValue +import com.instructure.horizon.features.learn.LearnEvent +import com.instructure.horizon.features.learn.LearnEventHandler +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryFilterScreenType +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LearnMyContentViewModelTest { + + private val eventHandler = LearnEventHandler() + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Initial state has InProgress selected tab`() = runTest { + val viewModel = getViewModel() + + assertEquals(LearnMyContentTab.InProgress, viewModel.uiState.value.selectedTab) + } + + @Test + fun `Initial state has MostRecent sort option`() = runTest { + val viewModel = getViewModel() + + assertEquals(LearnLearningLibrarySortOption.MostRecent, viewModel.uiState.value.sortByOption) + } + + @Test + fun `Initial state has All type filter`() = runTest { + val viewModel = getViewModel() + + assertEquals(LearnLearningLibraryTypeFilter.All, viewModel.uiState.value.typeFilter) + } + + @Test + fun `Initial state has zero active filter count`() = runTest { + val viewModel = getViewModel() + + assertEquals(0, viewModel.uiState.value.activeFilterCount) + } + + @Test + fun `Initial state has empty search query`() = runTest { + val viewModel = getViewModel() + + assertEquals("", viewModel.uiState.value.searchQuery.text) + } + + @Test + fun `updateSearchQuery updates searchQuery in state`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.updateSearchQuery(TextFieldValue("kotlin")) + + assertEquals("kotlin", viewModel.uiState.value.searchQuery.text) + } + + @Test + fun `onTabSelected updates selected tab`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.onTabSelected(LearnMyContentTab.Completed) + + assertEquals(LearnMyContentTab.Completed, viewModel.uiState.value.selectedTab) + } + + @Test + fun `onTabSelected resets sortByOption to MostRecent`() = runTest { + val viewModel = getViewModel() + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + typeFilter = LearnLearningLibraryTypeFilter.All, + sortOption = LearnLearningLibrarySortOption.NameAscending, + ) + ) + + viewModel.uiState.value.onTabSelected(LearnMyContentTab.Saved) + + assertEquals(LearnLearningLibrarySortOption.MostRecent, viewModel.uiState.value.sortByOption) + } + + @Test + fun `onTabSelected resets typeFilter to All`() = runTest { + val viewModel = getViewModel() + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + typeFilter = LearnLearningLibraryTypeFilter.Programs, + sortOption = LearnLearningLibrarySortOption.MostRecent, + ) + ) + + viewModel.uiState.value.onTabSelected(LearnMyContentTab.Completed) + + assertEquals(LearnLearningLibraryTypeFilter.All, viewModel.uiState.value.typeFilter) + } + + @Test + fun `onTabSelected resets activeFilterCount to zero`() = runTest { + val viewModel = getViewModel() + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + typeFilter = LearnLearningLibraryTypeFilter.Courses, + sortOption = LearnLearningLibrarySortOption.MostRecent, + ) + ) + + viewModel.uiState.value.onTabSelected(LearnMyContentTab.InProgress) + + assertEquals(0, viewModel.uiState.value.activeFilterCount) + } + + @Test + fun `UpdateLearningLibraryFilter with MyContent screenType updates sortByOption`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + typeFilter = LearnLearningLibraryTypeFilter.All, + sortOption = LearnLearningLibrarySortOption.NameDescending, + ) + ) + + assertEquals(LearnLearningLibrarySortOption.NameDescending, viewModel.uiState.value.sortByOption) + } + + @Test + fun `UpdateLearningLibraryFilter with MyContent screenType updates typeFilter`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + typeFilter = LearnLearningLibraryTypeFilter.Programs, + sortOption = LearnLearningLibrarySortOption.MostRecent, + ) + ) + + assertEquals(LearnLearningLibraryTypeFilter.Programs, viewModel.uiState.value.typeFilter) + } + + @Test + fun `UpdateLearningLibraryFilter with MyContent screenType increments activeFilterCount for non-All filter`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + typeFilter = LearnLearningLibraryTypeFilter.Courses, + sortOption = LearnLearningLibrarySortOption.MostRecent, + ) + ) + + assertEquals(1, viewModel.uiState.value.activeFilterCount) + } + + @Test + fun `UpdateLearningLibraryFilter with MyContent screenType resets activeFilterCount for All filter`() = runTest { + val viewModel = getViewModel() + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + typeFilter = LearnLearningLibraryTypeFilter.Programs, + sortOption = LearnLearningLibrarySortOption.MostRecent, + ) + ) + + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContent, + typeFilter = LearnLearningLibraryTypeFilter.All, + sortOption = LearnLearningLibrarySortOption.MostRecent, + ) + ) + + assertEquals(0, viewModel.uiState.value.activeFilterCount) + } + + @Test + fun `UpdateLearningLibraryFilter with MyContentSaved screenType updates state`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.MyContentSaved, + typeFilter = LearnLearningLibraryTypeFilter.Courses, + sortOption = LearnLearningLibrarySortOption.LeastRecent, + ) + ) + + assertEquals(LearnLearningLibraryTypeFilter.Courses, viewModel.uiState.value.typeFilter) + assertEquals(LearnLearningLibrarySortOption.LeastRecent, viewModel.uiState.value.sortByOption) + } + + @Test + fun `UpdateLearningLibraryFilter with Browse screenType is ignored`() = runTest { + val viewModel = getViewModel() + + eventHandler.postEvent( + LearnEvent.UpdateLearningLibraryFilter( + screenType = LearnLearningLibraryFilterScreenType.Browse, + typeFilter = LearnLearningLibraryTypeFilter.Programs, + sortOption = LearnLearningLibrarySortOption.NameAscending, + ) + ) + + assertEquals(LearnLearningLibraryTypeFilter.All, viewModel.uiState.value.typeFilter) + assertEquals(LearnLearningLibrarySortOption.MostRecent, viewModel.uiState.value.sortByOption) + assertEquals(0, viewModel.uiState.value.activeFilterCount) + } + + private fun getViewModel() = LearnMyContentViewModel(eventHandler) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentExtensionsTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentExtensionsTest.kt new file mode 100644 index 0000000000..d3dbd304ae --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/common/LearnMyContentExtensionsTest.kt @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.mycontent.common + +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Resources +import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem +import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.horizon.R +import com.instructure.horizon.features.learn.navigation.LearnRoute +import com.instructure.horizon.navigation.MainNavigationRoute +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.util.Date + +class LearnMyContentExtensionsTest { + + private val resources: Resources = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + + @Before + fun setup() { + val sharedPrefs: SharedPreferences = mockk(relaxed = true) + every { context.getSharedPreferences(any(), any()) } returns sharedPrefs + every { sharedPrefs.getInt(any(), any()) } returns 0 + ContextKeeper.appContext = context + + every { resources.getString(R.string.learnMyContentProgramLabel) } returns "Program" + every { resources.getString(R.string.learnMyContentCourseLabel) } returns "Course" + every { resources.getString(R.string.learnMyContentStartLearning) } returns "Start learning" + every { resources.getString(R.string.learnMyContentResumeLearning) } returns "Resume learning" + every { resources.getString(R.string.learnMyContentDurationHrsMin, any(), any()) } answers { + val arr = args[1] as Array<*> + "${arr[0]}h ${arr[1]}m" + } + every { resources.getString(R.string.learnMyContentDurationHrs, any()) } answers { + val arr = args[1] as Array<*> + "${arr[0]}h" + } + every { resources.getString(R.string.learnMyContentDurationMin, any()) } answers { + val arr = args[1] as Array<*> + "${arr[0]}m" + } + every { resources.getString(R.string.programTag_DateRange, any(), any()) } answers { + val arr = args[1] as Array<*> + "${arr[0]} - ${arr[1]}" + } + every { resources.getQuantityString(R.plurals.learnMyContentProgramCourseCount, any(), any()) } answers { + "${secondArg()} courses" + } + } + + // --- ProgramEnrollmentItem tests --- + + @Test + fun `ProgramEnrollmentItem toCardState has no imageUrl`() = runTest { + val state = createTestProgramItem().toCardState(resources) { null } + + assertNull(state.imageUrl) + } + + @Test + fun `ProgramEnrollmentItem toCardState sets correct name`() = runTest { + val state = createTestProgramItem(name = "My Program").toCardState(resources) { null } + + assertEquals("My Program", state.name) + } + + @Test + fun `ProgramEnrollmentItem toCardState sets correct progress`() = runTest { + val state = createTestProgramItem(completionPercentage = 75.0).toCardState(resources) { null } + + assertEquals(75.0, state.progress) + } + + @Test + fun `ProgramEnrollmentItem toCardState has no buttonState`() = runTest { + val state = createTestProgramItem().toCardState(resources) { null } + + assertNull(state.buttonState) + } + + @Test + fun `ProgramEnrollmentItem toCardState route points to program details screen`() = runTest { + val state = createTestProgramItem(id = "prog123").toCardState(resources) { null } + + assertEquals(LearnRoute.LearnProgramDetailsScreen.route("prog123"), state.route) + } + + @Test + fun `ProgramEnrollmentItem toCardState includes program type chip`() = runTest { + val state = createTestProgramItem().toCardState(resources) { null } + + assertTrue(state.cardChips.any { it.label == "Program" }) + } + + @Test + fun `ProgramEnrollmentItem toCardState includes course count chip`() = runTest { + val state = createTestProgramItem(courseCount = 3).toCardState(resources) { null } + + assertTrue(state.cardChips.any { it.label == "3 courses" }) + } + + @Test + fun `ProgramEnrollmentItem toCardState includes hours and minutes duration chip`() = runTest { + val state = createTestProgramItem(estimatedDurationMinutes = 90).toCardState(resources) { null } + + assertTrue(state.cardChips.any { it.label == "1h 30m" }) + } + + @Test + fun `ProgramEnrollmentItem toCardState includes hours-only duration chip`() = runTest { + val state = createTestProgramItem(estimatedDurationMinutes = 120).toCardState(resources) { null } + + assertTrue(state.cardChips.any { it.label == "2h" }) + } + + @Test + fun `ProgramEnrollmentItem toCardState includes minutes-only duration chip`() = runTest { + val state = createTestProgramItem(estimatedDurationMinutes = 45).toCardState(resources) { null } + + assertTrue(state.cardChips.any { it.label == "45m" }) + } + + @Test + fun `ProgramEnrollmentItem toCardState excludes duration chip when estimatedDurationMinutes is null`() = runTest { + val state = createTestProgramItem(estimatedDurationMinutes = null).toCardState(resources) { null } + + assertTrue(state.cardChips.none { it.label.matches(Regex("\\d.*[mh]")) }) + } + + @Test + fun `ProgramEnrollmentItem toCardState excludes duration chip when estimatedDurationMinutes is zero`() = runTest { + val state = createTestProgramItem(estimatedDurationMinutes = 0).toCardState(resources) { null } + + assertTrue(state.cardChips.none { it.label.matches(Regex("\\d.*[mh]")) }) + } + + @Test + fun `ProgramEnrollmentItem toCardState includes date range chip when both start and end dates present`() = runTest { + val state = createTestProgramItem(startDate = Date(0), endDate = Date(86400000L)).toCardState(resources) { null } + + assertTrue(state.cardChips.any { it.iconRes == R.drawable.calendar_today }) + } + + @Test + fun `ProgramEnrollmentItem toCardState excludes date range chip when startDate is null`() = runTest { + val state = createTestProgramItem(startDate = null, endDate = Date()).toCardState(resources) { null } + + assertTrue(state.cardChips.none { it.iconRes == R.drawable.calendar_today }) + } + + @Test + fun `ProgramEnrollmentItem toCardState excludes date range chip when endDate is null`() = runTest { + val state = createTestProgramItem(startDate = Date(), endDate = null).toCardState(resources) { null } + + assertTrue(state.cardChips.none { it.iconRes == R.drawable.calendar_today }) + } + + // --- CourseEnrollmentItem tests --- + + @Test + fun `CourseEnrollmentItem toCardState route points to course details screen`() = runTest { + val state = createTestCourseItem(id = "42").toCardState(resources) { null } + + assertEquals(LearnRoute.LearnCourseDetailsScreen.route(42L), state.route) + } + + @Test + fun `CourseEnrollmentItem toCardState includes course type chip`() = runTest { + val state = createTestCourseItem().toCardState(resources) { null } + + assertTrue(state.cardChips.any { it.label == "Course" }) + } + + @Test + fun `CourseEnrollmentItem toCardState with null completionPercentage has Start learning button`() = runTest { + val moduleRoute = MainNavigationRoute.ModuleItemSequence(courseId = 42L, moduleItemId = 1L) + + val state = createTestCourseItem(completionPercentage = null).toCardState(resources) { moduleRoute } + + assertEquals("Start learning", state.buttonState?.label) + } + + @Test + fun `CourseEnrollmentItem toCardState with zero completionPercentage has Start learning button`() = runTest { + val moduleRoute = MainNavigationRoute.ModuleItemSequence(courseId = 42L, moduleItemId = 1L) + + val state = createTestCourseItem(completionPercentage = 0.0).toCardState(resources) { moduleRoute } + + assertEquals("Start learning", state.buttonState?.label) + } + + @Test + fun `CourseEnrollmentItem toCardState with partial progress has Resume learning button`() = runTest { + val moduleRoute = MainNavigationRoute.ModuleItemSequence(courseId = 42L, moduleItemId = 1L) + + val state = createTestCourseItem(completionPercentage = 55.0).toCardState(resources) { moduleRoute } + + assertEquals("Resume learning", state.buttonState?.label) + } + + @Test + fun `CourseEnrollmentItem toCardState with 100 percent completion has no button`() = runTest { + val state = createTestCourseItem(completionPercentage = 100.0).toCardState(resources) { null } + + assertNull(state.buttonState) + } + + @Test + fun `CourseEnrollmentItem toCardState button route is the returned ModuleItemSequence route`() = runTest { + val moduleRoute = MainNavigationRoute.ModuleItemSequence(courseId = 123L, moduleItemId = 456L) + + val state = createTestCourseItem(id = "123", completionPercentage = 50.0).toCardState(resources) { moduleRoute } + + assertNotNull(state.buttonState) + assertTrue(state.buttonState!!.route is MainNavigationRoute.ModuleItemSequence) + val route = state.buttonState!!.route as MainNavigationRoute.ModuleItemSequence + assertEquals(456L, route.moduleItemId) + } + + @Test + fun `CourseEnrollmentItem toCardState without moduleItem has no button despite incomplete progress`() = runTest { + val state = createTestCourseItem(completionPercentage = 50.0).toCardState(resources) { null } + + assertNull(state.buttonState) + } + + @Test + fun `CourseEnrollmentItem toCardState sets imageUrl from item`() = runTest { + val state = createTestCourseItem(imageUrl = "https://example.com/img.jpg").toCardState(resources) { null } + + assertEquals("https://example.com/img.jpg", state.imageUrl) + } + + @Test + fun `CourseEnrollmentItem toCardState includes date range chip when both dates present`() = runTest { + val state = createTestCourseItem(startAt = Date(0), endAt = Date(86400000L)).toCardState(resources) { null } + + assertTrue(state.cardChips.any { it.iconRes == R.drawable.calendar_today }) + } + + @Test + fun `CourseEnrollmentItem toCardState excludes date range chip when only startAt is set`() = runTest { + val state = createTestCourseItem(startAt = Date(), endAt = null).toCardState(resources) { null } + + assertTrue(state.cardChips.none { it.iconRes == R.drawable.calendar_today }) + } + + private fun createTestProgramItem( + id: String = "program1", + name: String = "Test Program", + completionPercentage: Double? = 50.0, + startDate: Date? = null, + endDate: Date? = null, + estimatedDurationMinutes: Int? = null, + courseCount: Int = 2, + ) = ProgramEnrollmentItem( + id = id, + name = name, + position = 1, + enrolledAt = Date(), + completionPercentage = completionPercentage, + startDate = startDate, + endDate = endDate, + status = "active", + description = null, + variant = "standard", + estimatedDurationMinutes = estimatedDurationMinutes, + courseCount = courseCount, + ) + + private fun createTestCourseItem( + id: String = "42", + name: String = "Test Course", + completionPercentage: Double? = 50.0, + imageUrl: String? = null, + startAt: Date? = null, + endAt: Date? = null, + ) = CourseEnrollmentItem( + id = id, + name = name, + position = 1, + enrolledAt = Date(), + completionPercentage = completionPercentage, + startAt = startAt, + endAt = endAt, + requirementCount = 10, + requirementCompletedCount = 5, + completedAt = null, + grade = null, + imageUrl = imageUrl, + workflowState = "available", + lastActivityAt = null, + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt new file mode 100644 index 0000000000..af64d41ba6 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/completed/LearnMyContentCompletedViewModelTest.kt @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.mycontent.completed + +import android.content.res.Resources +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter +import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class LearnMyContentCompletedViewModelTest { + + private val resources: Resources = mockk(relaxed = true) + private val repository: LearnMyContentRepository = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + private val emptyResponse = LearnItemsResponse( + items = emptyList(), + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + every { resources.getString(any()) } returns "" + every { resources.getString(any(), *anyVararg()) } returns "" + every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "" + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns emptyResponse + coEvery { repository.getFirstPageModulesWithItems(any(), any()) } returns emptyList() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `onFiltersChanged triggers load with COMPLETED status only`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = CollectionItemSortOption.MOST_RECENT, + status = listOf(LearnItemStatus.COMPLETED), + itemTypes = null, + forceNetwork = false, + ) + } + } + + @Test + fun `onFiltersChanged does NOT pass IN_PROGRESS or NOT_STARTED status`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coVerify(exactly = 0) { + repository.getLearnItems( + status = match { it.contains(LearnItemStatus.IN_PROGRESS) || it.contains(LearnItemStatus.NOT_STARTED) }, + cursor = any(), + searchQuery = any(), + sortBy = any(), + itemTypes = any(), + forceNetwork = any(), + ) + } + } + + @Test + fun `onFiltersChanged with Programs typeFilter passes PROGRAM item type`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Programs) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = any(), + status = listOf(LearnItemStatus.COMPLETED), + itemTypes = listOf(LearnItemType.PROGRAM), + forceNetwork = false, + ) + } + } + + @Test + fun `onFiltersChanged with Courses typeFilter passes COURSE item type`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Courses) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = any(), + status = listOf(LearnItemStatus.COMPLETED), + itemTypes = listOf(LearnItemType.COURSE), + forceNetwork = false, + ) + } + } + + @Test + fun `Successful load populates contentCards`() = runTest { + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + items = listOf(createTestProgramItem(name = "Completed Program")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertEquals(1, viewModel.uiState.value.contentCards.size) + assertEquals("Completed Program", viewModel.uiState.value.contentCards[0].name) + } + + @Test + fun `Load error sets isError true`() = runTest { + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertTrue(viewModel.uiState.value.loadingState.isError) + } + + @Test + fun `showMoreButton is true when pageInfo has next page`() = runTest { + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + items = listOf(createTestProgramItem()), + pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 10, null) + ) + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertTrue(viewModel.uiState.value.showMoreButton) + } + + @Test + fun `Refresh calls repository with forceNetwork true`() = runTest { + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.uiState.value.loadingState.onRefresh() + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = any(), + status = listOf(LearnItemStatus.COMPLETED), + itemTypes = null, + forceNetwork = true, + ) + } + } + + @Test + fun `Refresh error shows snackbar message`() = runTest { + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + + viewModel.uiState.value.loadingState.onRefresh() + + assertFalse(viewModel.uiState.value.loadingState.isRefreshing) + assertNotNull(viewModel.uiState.value.loadingState.snackbarMessage) + } + + @Test + fun `loadMore fetches with nextCursor and appends items`() = runTest { + val firstPage = LearnItemsResponse( + items = listOf(createTestProgramItem(id = "p1", name = "First")), + pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 2, null) + ) + val secondPage = LearnItemsResponse( + items = listOf(createTestProgramItem(id = "p2", name = "Second")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 2, null) + ) + coEvery { repository.getLearnItems(null, any(), any(), any(), any(), any()) } returns firstPage + coEvery { repository.getLearnItems("cursor1", any(), any(), any(), any(), any()) } returns secondPage + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.uiState.value.increaseTotalItemCount() + + assertEquals(2, viewModel.uiState.value.contentCards.size) + assertEquals("First", viewModel.uiState.value.contentCards[0].name) + assertEquals("Second", viewModel.uiState.value.contentCards[1].name) + } + + private fun getViewModel() = LearnMyContentCompletedViewModel(resources, repository) + + private fun createTestProgramItem( + id: String = "program1", + name: String = "Test Program", + ) = ProgramEnrollmentItem( + id = id, + name = name, + position = 1, + enrolledAt = Date(), + completionPercentage = 100.0, + startDate = null, + endDate = null, + status = "completed", + description = null, + variant = "standard", + estimatedDurationMinutes = null, + courseCount = 2, + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt new file mode 100644 index 0000000000..16a561479a --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/inprogress/LearnMyContentInProgressViewModelTest.kt @@ -0,0 +1,465 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.mycontent.inprogress + +import android.content.res.Resources +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.canvasapi2.models.journey.mycontent.CourseEnrollmentItem +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemStatus +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemType +import com.instructure.canvasapi2.models.journey.mycontent.LearnItemsResponse +import com.instructure.canvasapi2.models.journey.mycontent.ProgramEnrollmentItem +import com.instructure.horizon.R +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter +import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class LearnMyContentInProgressViewModelTest { + + private val resources: Resources = mockk(relaxed = true) + private val repository: LearnMyContentRepository = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + private val emptyResponse = LearnItemsResponse( + items = emptyList(), + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + every { resources.getString(any()) } returns "" + every { resources.getString(any(), *anyVararg()) } returns "" + every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "" + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns emptyResponse + coEvery { repository.getFirstPageModulesWithItems(any(), any()) } returns emptyList() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Initial state has empty content cards`() = runTest { + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.contentCards.isEmpty()) + } + + @Test + fun `Initial state is not loading`() = runTest { + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.loadingState.isLoading) + } + + @Test + fun `Initial state has no error`() = runTest { + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.loadingState.isError) + } + + @Test + fun `Initial state showMoreButton is false`() = runTest { + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.showMoreButton) + } + + @Test + fun `Initial state isMoreLoading is false`() = runTest { + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.isMoreLoading) + } + + @Test + fun `onFiltersChanged triggers load with IN_PROGRESS and NOT_STARTED status`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = CollectionItemSortOption.MOST_RECENT, + status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), + itemTypes = null, + forceNetwork = false, + ) + } + } + + @Test + fun `onFiltersChanged with Programs typeFilter passes PROGRAM item type`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Programs) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = CollectionItemSortOption.MOST_RECENT, + status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), + itemTypes = listOf(LearnItemType.PROGRAM), + forceNetwork = false, + ) + } + } + + @Test + fun `onFiltersChanged with Courses typeFilter passes COURSE item type`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Courses) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = CollectionItemSortOption.MOST_RECENT, + status = listOf(LearnItemStatus.IN_PROGRESS, LearnItemStatus.NOT_STARTED), + itemTypes = listOf(LearnItemType.COURSE), + forceNetwork = false, + ) + } + } + + @Test + fun `onFiltersChanged with All typeFilter passes null item types`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = any(), + status = any(), + itemTypes = null, + forceNetwork = false, + ) + } + } + + @Test + fun `onFiltersChanged with NameAscending sort passes NAME_A_Z sort option`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.NameAscending, LearnLearningLibraryTypeFilter.All) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = CollectionItemSortOption.NAME_A_Z, + status = any(), + itemTypes = null, + forceNetwork = false, + ) + } + } + + @Test + fun `Successful load populates contentCards`() = runTest { + val programs = listOf(createTestProgramItem(id = "p1", name = "Program A")) + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + items = programs, + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertEquals(1, viewModel.uiState.value.contentCards.size) + assertEquals("Program A", viewModel.uiState.value.contentCards[0].name) + } + + @Test + fun `Successful load sets totalItemCount from pageInfo`() = runTest { + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + items = listOf(createTestProgramItem()), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 42, null) + ) + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertEquals(42, viewModel.uiState.value.totalItemCount) + } + + @Test + fun `showMoreButton is true when pageInfo has next page`() = runTest { + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + items = listOf(createTestProgramItem()), + pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 10, null) + ) + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertTrue(viewModel.uiState.value.showMoreButton) + } + + @Test + fun `showMoreButton is false when pageInfo has no next page`() = runTest { + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + items = listOf(createTestProgramItem()), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertFalse(viewModel.uiState.value.showMoreButton) + } + + @Test + fun `Load error sets isError true`() = runTest { + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertTrue(viewModel.uiState.value.loadingState.isError) + assertFalse(viewModel.uiState.value.loadingState.isLoading) + } + + @Test + fun `Filter change replaces existing items`() = runTest { + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + items = listOf(createTestProgramItem(name = "First")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), any()) } returns LearnItemsResponse( + items = listOf(createTestProgramItem(name = "Second")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Programs) + + assertEquals(1, viewModel.uiState.value.contentCards.size) + assertEquals("Second", viewModel.uiState.value.contentCards[0].name) + } + + @Test + fun `Refresh calls repository with forceNetwork true`() = runTest { + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.uiState.value.loadingState.onRefresh() + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = any(), + status = any(), + itemTypes = null, + forceNetwork = true, + ) + } + } + + @Test + fun `Refresh success clears error and updates content`() = runTest { + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), false) } throws Exception("Error") + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } returns LearnItemsResponse( + items = listOf(createTestProgramItem(name = "Refreshed")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + viewModel.uiState.value.loadingState.onRefresh() + + assertFalse(viewModel.uiState.value.loadingState.isError) + assertFalse(viewModel.uiState.value.loadingState.isRefreshing) + assertEquals("Refreshed", viewModel.uiState.value.contentCards[0].name) + } + + @Test + fun `Refresh error shows snackbar message`() = runTest { + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + + viewModel.uiState.value.loadingState.onRefresh() + + assertFalse(viewModel.uiState.value.loadingState.isRefreshing) + assertNotNull(viewModel.uiState.value.loadingState.snackbarMessage) + } + + @Test + fun `Dismiss snackbar clears snackbar message`() = runTest { + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + coEvery { repository.getLearnItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + viewModel.uiState.value.loadingState.onRefresh() + + viewModel.uiState.value.loadingState.onSnackbarDismiss() + + assertNull(viewModel.uiState.value.loadingState.snackbarMessage) + } + + @Test + fun `loadMore fetches with nextCursor and appends items`() = runTest { + val firstPage = LearnItemsResponse( + items = listOf(createTestProgramItem(id = "p1", name = "First")), + pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 2, null) + ) + val secondPage = LearnItemsResponse( + items = listOf(createTestProgramItem(id = "p2", name = "Second")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 2, null) + ) + coEvery { repository.getLearnItems(null, any(), any(), any(), any(), any()) } returns firstPage + coEvery { repository.getLearnItems("cursor1", any(), any(), any(), any(), any()) } returns secondPage + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.uiState.value.increaseTotalItemCount() + + assertEquals(2, viewModel.uiState.value.contentCards.size) + assertEquals("First", viewModel.uiState.value.contentCards[0].name) + assertEquals("Second", viewModel.uiState.value.contentCards[1].name) + } + + @Test + fun `loadMore error shows snackbar and clears isMoreLoading`() = runTest { + coEvery { repository.getLearnItems(null, any(), any(), any(), any(), any()) } returns LearnItemsResponse( + items = listOf(createTestProgramItem()), + pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 2, null) + ) + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + coEvery { repository.getLearnItems("cursor1", any(), any(), any(), any(), any()) } throws Exception("Network error") + + viewModel.uiState.value.increaseTotalItemCount() + + assertFalse(viewModel.uiState.value.isMoreLoading) + assertNotNull(viewModel.uiState.value.loadingState.snackbarMessage) + } + + @Test + fun `Search query triggers load with searchQuery after debounce`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("kotlin", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + advanceTimeBy(350) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = "kotlin", + sortBy = any(), + status = any(), + itemTypes = any(), + forceNetwork = false, + ) + } + } + + @Test + fun `Empty search query passes null searchQuery to repository`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coVerify { + repository.getLearnItems( + cursor = null, + searchQuery = null, + sortBy = any(), + status = any(), + itemTypes = any(), + forceNetwork = false, + ) + } + } + + private fun getViewModel() = LearnMyContentInProgressViewModel(resources, repository) + + private fun createTestProgramItem( + id: String = "program1", + name: String = "Test Program", + ) = ProgramEnrollmentItem( + id = id, + name = name, + position = 1, + enrolledAt = Date(), + completionPercentage = 50.0, + startDate = null, + endDate = null, + status = "active", + description = null, + variant = "standard", + estimatedDurationMinutes = null, + courseCount = 2, + ) + + private fun createTestCourseItem( + id: String = "course1", + name: String = "Test Course", + completionPercentage: Double? = 50.0, + ) = CourseEnrollmentItem( + id = id, + name = name, + position = 1, + enrolledAt = Date(), + completionPercentage = completionPercentage, + startAt = null, + endAt = null, + requirementCount = 10, + requirementCompletedCount = 5, + completedAt = null, + grade = null, + imageUrl = null, + workflowState = "available", + lastActivityAt = null, + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt new file mode 100644 index 0000000000..4c620cdaca --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/mycontent/saved/LearnMyContentSavedViewModelTest.kt @@ -0,0 +1,348 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.learn.mycontent.saved + +import android.content.res.Resources +import com.instructure.canvasapi2.models.journey.learninglibrary.CanvasCourseInfo +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemSortOption +import com.instructure.canvasapi2.models.journey.learninglibrary.CollectionItemType +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItem +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryCollectionItemsResponse +import com.instructure.canvasapi2.models.journey.learninglibrary.LearningLibraryPageInfo +import com.instructure.horizon.R +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibrarySortOption +import com.instructure.horizon.features.learn.learninglibrary.common.LearnLearningLibraryTypeFilter +import com.instructure.horizon.features.learn.mycontent.common.LearnMyContentRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class LearnMyContentSavedViewModelTest { + + private val resources: Resources = mockk(relaxed = true) + private val myContentRepository: LearnMyContentRepository = mockk(relaxed = true) + private val savedContentRepository: LearnMyContentSavedRepository = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + private val emptyResponse = LearningLibraryCollectionItemsResponse( + items = emptyList(), + pageInfo = LearningLibraryPageInfo(null, null, false, false, null, null) + ) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + every { resources.getString(any()) } returns "" + every { resources.getString(any(), *anyVararg()) } returns "" + every { resources.getQuantityString(any(), any(), *anyVararg()) } returns "" + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns emptyResponse + coEvery { myContentRepository.getFirstPageModulesWithItems(any(), any()) } returns emptyList() + coEvery { savedContentRepository.getLearningLibraryRecommendedItems(any()) } returns emptyList() + coEvery { savedContentRepository.toggleLearningLibraryItemIsBookmarked(any()) } returns false + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Initial state has empty content cards`() = runTest { + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.contentCards.isEmpty()) + } + + @Test + fun `onFiltersChanged calls getBookmarkedLearningLibraryItems`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coVerify { + myContentRepository.getBookmarkedLearningLibraryItems( + afterCursor = null, + searchQuery = null, + sortBy = CollectionItemSortOption.MOST_RECENT, + types = null, + forceNetwork = false, + ) + } + } + + @Test + fun `onFiltersChanged also fetches recommendations`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coVerify { savedContentRepository.getLearningLibraryRecommendedItems(false) } + } + + @Test + fun `onFiltersChanged with Courses typeFilter passes COURSE collection type`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.Courses) + + coVerify { + myContentRepository.getBookmarkedLearningLibraryItems( + afterCursor = null, + searchQuery = null, + sortBy = any(), + types = listOf(CollectionItemType.COURSE), + forceNetwork = false, + ) + } + } + + @Test + fun `onFiltersChanged with All typeFilter passes null types`() = runTest { + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + coVerify { + myContentRepository.getBookmarkedLearningLibraryItems( + afterCursor = null, + searchQuery = null, + sortBy = any(), + types = null, + forceNetwork = false, + ) + } + } + + @Test + fun `Successful load populates contentCards`() = runTest { + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item1", name = "Saved Course")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertEquals(1, viewModel.uiState.value.contentCards.size) + assertEquals("item1", viewModel.uiState.value.contentCards[0].id) + } + + @Test + fun `Load error sets isError true`() = runTest { + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertTrue(viewModel.uiState.value.loadingState.isError) + } + + @Test + fun `showMoreButton is true when pageInfo has next page`() = runTest { + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem()), + pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 10, null) + ) + val viewModel = getViewModel() + + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + assertTrue(viewModel.uiState.value.showMoreButton) + } + + @Test + fun `Refresh fetches recommendations with forceNetwork true`() = runTest { + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.uiState.value.loadingState.onRefresh() + + coVerify { savedContentRepository.getLearningLibraryRecommendedItems(true) } + } + + @Test + fun `Refresh calls getBookmarkedLearningLibraryItems with forceNetwork true`() = runTest { + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.uiState.value.loadingState.onRefresh() + + coVerify { + myContentRepository.getBookmarkedLearningLibraryItems( + afterCursor = null, + searchQuery = null, + sortBy = any(), + types = null, + forceNetwork = true, + ) + } + } + + @Test + fun `loadMore fetches with cursor and appends items`() = runTest { + val firstPage = LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item1")), + pageInfo = LearningLibraryPageInfo("cursor1", null, true, false, 2, null) + ) + val secondPage = LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item2")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 2, null) + ) + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(null, any(), any(), any(), any(), any()) } returns firstPage + coEvery { myContentRepository.getBookmarkedLearningLibraryItems("cursor1", any(), any(), any(), any(), any()) } returns secondPage + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.uiState.value.increaseTotalItemCount() + + assertEquals(2, viewModel.uiState.value.contentCards.size) + } + + @Test + fun `onBookmarkItem sets bookmarkLoading true then removes item on success`() = runTest { + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item1")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.onBookmarkItem("item1") + + assertTrue(viewModel.uiState.value.contentCards.none { it.id == "item1" }) + } + + @Test + fun `onBookmarkItem calls toggleLearningLibraryItemIsBookmarked`() = runTest { + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item1")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.onBookmarkItem("item1") + + coVerify { savedContentRepository.toggleLearningLibraryItemIsBookmarked("item1") } + } + + @Test + fun `onBookmarkItem error keeps item in list with bookmarkLoading false`() = runTest { + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item1")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + coEvery { savedContentRepository.toggleLearningLibraryItemIsBookmarked("item1") } throws Exception("Network error") + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.onBookmarkItem("item1") + + val item = viewModel.uiState.value.contentCards.find { it.id == "item1" } + assertNotNull(item) + assertFalse(item!!.bookmarkLoading) + } + + @Test + fun `onBookmarkItem error shows snackbar message`() = runTest { + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), any()) } returns LearningLibraryCollectionItemsResponse( + items = listOf(createTestCollectionItem(id = "item1")), + pageInfo = LearningLibraryPageInfo(null, null, false, false, 1, null) + ) + every { resources.getString(R.string.learnMyContentSavedFailedToBookmarkErrorMessage) } returns "Failed to save" + coEvery { savedContentRepository.toggleLearningLibraryItemIsBookmarked("item1") } throws Exception("Network error") + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + + viewModel.onBookmarkItem("item1") + + assertNotNull(viewModel.uiState.value.loadingState.snackbarMessage) + } + + @Test + fun `Refresh error shows snackbar`() = runTest { + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), true) } throws Exception("Network error") + + viewModel.uiState.value.loadingState.onRefresh() + + assertFalse(viewModel.uiState.value.loadingState.isRefreshing) + assertNotNull(viewModel.uiState.value.loadingState.snackbarMessage) + } + + @Test + fun `Dismiss snackbar clears snackbar message`() = runTest { + val viewModel = getViewModel() + viewModel.onFiltersChanged("", LearnLearningLibrarySortOption.MostRecent, LearnLearningLibraryTypeFilter.All) + coEvery { myContentRepository.getBookmarkedLearningLibraryItems(any(), any(), any(), any(), any(), true) } throws Exception("Error") + viewModel.uiState.value.loadingState.onRefresh() + + viewModel.uiState.value.loadingState.onSnackbarDismiss() + + assertNull(viewModel.uiState.value.loadingState.snackbarMessage) + } + + private fun getViewModel() = LearnMyContentSavedViewModel(resources, myContentRepository, savedContentRepository) + + private fun createTestCollectionItem( + id: String = "testItem", + name: String = "Test Item", + isBookmarked: Boolean = true, + itemType: CollectionItemType = CollectionItemType.COURSE, + ) = LearningLibraryCollectionItem( + id = id, + libraryId = "library1", + itemType = itemType, + displayOrder = 1.0, + canvasCourse = CanvasCourseInfo( + courseId = "1", + canvasUrl = "https://example.com", + courseName = name, + courseImageUrl = null, + moduleCount = 5.0, + moduleItemCount = 20.0, + estimatedDurationMinutes = 60.0, + ), + programId = null, + programCourseId = null, + createdAt = Date(), + updatedAt = Date(), + isBookmarked = isBookmarked, + completionPercentage = 0.0, + isEnrolledInCanvas = true, + moduleInfo = null, + canvasEnrollmentId = null, + ) +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/list/LearnProgramListRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/list/LearnProgramListRepositoryTest.kt deleted file mode 100644 index dec2a5ca54..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/list/LearnProgramListRepositoryTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.program.list - -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager -import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.journey.type.ProgramVariantType -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -class LearnProgramListRepositoryTest { - private val getProgramsManager: GetProgramsManager = mockk(relaxed = true) - private val getCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) - - private val testPrograms = listOf( - createTestProgram( - id = "program1", - name = "Software Engineering", - requirements = listOf( - createTestProgramRequirement(courseId = 1L, progress = 0.0), - createTestProgramRequirement(courseId = 2L, progress = 50.0) - ) - ), - createTestProgram( - id = "program2", - name = "Data Science", - requirements = listOf( - createTestProgramRequirement(courseId = 3L, progress = 100.0) - ) - ) - ) - - private val testCourses = listOf( - createTestCourse(courseId = 1L, courseName = "Intro to Programming"), - createTestCourse(courseId = 2L, courseName = "Advanced Algorithms"), - createTestCourse(courseId = 3L, courseName = "Machine Learning") - ) - - @Before - fun setup() { - coEvery { getProgramsManager.getPrograms(any()) } returns testPrograms - coEvery { getCoursesManager.getProgramCourses(any(), any()) } returns DataResult.Success(testCourses[0]) - } - - @Test - fun `getPrograms returns list of programs`() = runTest { - val repository = getRepository() - val result = repository.getPrograms(false) - - assertEquals(2, result.size) - assertEquals(testPrograms, result) - coVerify { getProgramsManager.getPrograms(false) } - } - - @Test - fun `getPrograms with forceRefresh true calls API with force network`() = runTest { - val repository = getRepository() - repository.getPrograms(true) - - coVerify { getProgramsManager.getPrograms(true) } - } - - @Test - fun `getPrograms returns empty list when no programs`() = runTest { - coEvery { getProgramsManager.getPrograms(any()) } returns emptyList() - val repository = getRepository() - val result = repository.getPrograms(false) - - assertEquals(0, result.size) - } - - @Test - fun `getCoursesById fetches courses for all provided IDs`() = runTest { - coEvery { getCoursesManager.getProgramCourses(1L, false) } returns DataResult.Success(testCourses[0]) - coEvery { getCoursesManager.getProgramCourses(2L, false) } returns DataResult.Success(testCourses[1]) - coEvery { getCoursesManager.getProgramCourses(3L, false) } returns DataResult.Success(testCourses[2]) - - val repository = getRepository() - val result = repository.getCoursesById(listOf(1L, 2L, 3L), false) - - assertEquals(3, result.size) - assertEquals("Intro to Programming", result[0].courseName) - assertEquals("Advanced Algorithms", result[1].courseName) - assertEquals("Machine Learning", result[2].courseName) - coVerify { getCoursesManager.getProgramCourses(1L, false) } - coVerify { getCoursesManager.getProgramCourses(2L, false) } - coVerify { getCoursesManager.getProgramCourses(3L, false) } - } - - @Test - fun `getCoursesById with forceNetwork true calls API with force network`() = runTest { - coEvery { getCoursesManager.getProgramCourses(1L, true) } returns DataResult.Success(testCourses[0]) - - val repository = getRepository() - repository.getCoursesById(listOf(1L), true) - - coVerify { getCoursesManager.getProgramCourses(1L, true) } - } - - private fun getRepository(): LearnProgramListRepository { - return LearnProgramListRepository(getProgramsManager, getCoursesManager) - } - - private fun createTestProgram( - id: String = "testProgram", - name: String = "Test Program", - requirements: List = emptyList() - ): Program = Program( - id = id, - name = name, - description = "Test description", - startDate = null, - endDate = null, - variant = ProgramVariantType.LINEAR, - courseCompletionCount = null, - sortedRequirements = requirements - ) - - private fun createTestProgramRequirement( - courseId: Long = 1L, - progress: Double = 0.0 - ): ProgramRequirement = ProgramRequirement( - id = "requirement$courseId", - progressId = "progress$courseId", - courseId = courseId, - required = true, - progress = progress, - enrollmentStatus = null - ) - - private fun createTestCourse( - courseId: Long = 1L, - courseName: String = "Test Course" - ): CourseWithModuleItemDurations = CourseWithModuleItemDurations( - courseId = courseId, - courseName = courseName, - moduleItemsDuration = listOf("PT1H"), - startDate = null, - endDate = null - ) -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/list/LearnProgramListViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/list/LearnProgramListViewModelTest.kt deleted file mode 100644 index c21406a9f9..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/program/list/LearnProgramListViewModelTest.kt +++ /dev/null @@ -1,434 +0,0 @@ -/* - * Copyright (C) 2026 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.learn.program.list - -import android.content.Context -import android.content.res.Resources -import androidx.compose.ui.text.input.TextFieldValue -import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithModuleItemDurations -import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program -import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement -import com.instructure.horizon.R -import com.instructure.journey.type.ProgramVariantType -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertFalse -import junit.framework.TestCase.assertNull -import junit.framework.TestCase.assertTrue -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class LearnProgramListViewModelTest { - private val context: Context = mockk(relaxed = true) - private val resources: Resources = mockk(relaxed = true) - private val repository: LearnProgramListRepository = mockk(relaxed = true) - private val testDispatcher = UnconfinedTestDispatcher() - - private val testPrograms = listOf( - createTestProgram( - id = "program1", - name = "Software Engineering", - requirements = listOf( - createTestProgramRequirement(courseId = 1L, progress = 0.0) - ) - ), - createTestProgram( - id = "program2", - name = "Data Science", - requirements = listOf( - createTestProgramRequirement(courseId = 2L, progress = 50.0) - ) - ), - createTestProgram( - id = "program3", - name = "Web Development", - requirements = listOf( - createTestProgramRequirement(courseId = 3L, progress = 100.0) - ) - ), - createTestProgram( - id = "program4", - name = "Machine Learning", - requirements = listOf( - createTestProgramRequirement(courseId = 4L, progress = 25.0) - ) - ) - ) - - private val testCourses = listOf( - createTestCourse(courseId = 1L, courseName = "Intro to Programming"), - createTestCourse(courseId = 2L, courseName = "Data Analysis"), - createTestCourse(courseId = 3L, courseName = "React Development"), - createTestCourse(courseId = 4L, courseName = "Neural Networks") - ) - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - every { context.getString(any()) } returns "" - every { context.getString(any(), any()) } returns "" - every { context.getString(any(), any(), any()) } returns "" - every { resources.getQuantityString(any(), any(), any()) } returns "2 courses" - every { resources.getString(any()) } returns "" - every { resources.getString(any(), any()) } returns "" - coEvery { repository.getPrograms(any()) } returns testPrograms - coEvery { repository.getCoursesById(any(), any()) } returns testCourses - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `Initial state loads programs successfully`() { - val viewModel = getViewModel() - - val state = viewModel.uiState.value - assertFalse(state.loadingState.isLoading) - assertFalse(state.loadingState.isError) - assertEquals(4, state.filteredPrograms.size) - coVerify { repository.getPrograms(false) } - } - - @Test - fun `Initial state sets default filter to All`() { - val viewModel = getViewModel() - - val state = viewModel.uiState.value - assertEquals(LearnProgramFilterOption.All, state.selectedFilterValue) - } - - @Test - fun `Initial state sets visible item count to 10`() { - val viewModel = getViewModel() - - val state = viewModel.uiState.value - assertEquals(10, state.visibleItemCount) - } - - @Test - fun `Initial state has empty search query`() { - val viewModel = getViewModel() - - val state = viewModel.uiState.value - assertEquals("", state.searchQuery.text) - } - - @Test - fun `Loading state shows error when repository fails`() { - coEvery { repository.getPrograms(any()) } throws Exception("Network error") - val viewModel = getViewModel() - - val state = viewModel.uiState.value - assertTrue(state.loadingState.isError) - assertFalse(state.loadingState.isLoading) - } - - @Test - fun `Empty programs list loads successfully`() { - coEvery { repository.getPrograms(any()) } returns emptyList() - val viewModel = getViewModel() - - val state = viewModel.uiState.value - assertFalse(state.loadingState.isLoading) - assertFalse(state.loadingState.isError) - assertEquals(0, state.filteredPrograms.size) - } - - @Test - fun `Filter by NotStarted shows only programs with 0 progress`() { - val viewModel = getViewModel() - - viewModel.uiState.value.updateFilterValue(LearnProgramFilterOption.NotStarted) - - val state = viewModel.uiState.value - assertEquals(1, state.filteredPrograms.size) - assertEquals("Software Engineering", state.filteredPrograms[0].programName) - assertEquals(0.0, state.filteredPrograms[0].programProgress) - } - - @Test - fun `Filter by InProgress shows only programs with 0-100 progress`() { - val viewModel = getViewModel() - - viewModel.uiState.value.updateFilterValue(LearnProgramFilterOption.InProgress) - - val state = viewModel.uiState.value - assertEquals(2, state.filteredPrograms.size) - assertTrue(state.filteredPrograms.any { it.programName == "Data Science" }) - assertTrue(state.filteredPrograms.any { it.programName == "Machine Learning" }) - } - - @Test - fun `Filter by Completed shows only programs with 100 progress`() { - val viewModel = getViewModel() - - viewModel.uiState.value.updateFilterValue(LearnProgramFilterOption.Completed) - - val state = viewModel.uiState.value - assertEquals(1, state.filteredPrograms.size) - assertEquals("Web Development", state.filteredPrograms[0].programName) - assertEquals(100.0, state.filteredPrograms[0].programProgress) - } - - @Test - fun `Filter by All shows all programs`() { - val viewModel = getViewModel() - viewModel.uiState.value.updateFilterValue(LearnProgramFilterOption.NotStarted) - - viewModel.uiState.value.updateFilterValue(LearnProgramFilterOption.All) - - val state = viewModel.uiState.value - assertEquals(4, state.filteredPrograms.size) - } - - @Test - fun `Search query filters programs by name case-insensitive`() { - val viewModel = getViewModel() - - viewModel.uiState.value.updateSearchQuery(TextFieldValue("engineering")) - - val state = viewModel.uiState.value - assertEquals(1, state.filteredPrograms.size) - assertEquals("Software Engineering", state.filteredPrograms[0].programName) - } - - @Test - fun `Search query with partial match filters correctly`() { - val viewModel = getViewModel() - - viewModel.uiState.value.updateSearchQuery(TextFieldValue("dev")) - - val state = viewModel.uiState.value - assertEquals(1, state.filteredPrograms.size) - assertEquals("Web Development", state.filteredPrograms[0].programName) - } - - @Test - fun `Search query with no match returns empty list`() { - val viewModel = getViewModel() - - viewModel.uiState.value.updateSearchQuery(TextFieldValue("NonExistentProgram")) - - val state = viewModel.uiState.value - assertEquals(0, state.filteredPrograms.size) - } - - @Test - fun `Search query trims whitespace`() { - val viewModel = getViewModel() - - viewModel.uiState.value.updateSearchQuery(TextFieldValue(" data science ")) - - val state = viewModel.uiState.value - assertEquals(1, state.filteredPrograms.size) - assertEquals("Data Science", state.filteredPrograms[0].programName) - } - - @Test - fun `Combined filter and search works correctly`() { - val viewModel = getViewModel() - - viewModel.uiState.value.updateFilterValue(LearnProgramFilterOption.InProgress) - viewModel.uiState.value.updateSearchQuery(TextFieldValue("data")) - - val state = viewModel.uiState.value - assertEquals(1, state.filteredPrograms.size) - assertEquals("Data Science", state.filteredPrograms[0].programName) - } - - @Test - fun `Combined filter and search with no match returns empty list`() { - val viewModel = getViewModel() - - viewModel.uiState.value.updateFilterValue(LearnProgramFilterOption.Completed) - viewModel.uiState.value.updateSearchQuery(TextFieldValue("engineering")) - - val state = viewModel.uiState.value - assertEquals(0, state.filteredPrograms.size) - } - - @Test - fun `increaseVisibleItemCount increases count by 10`() { - val viewModel = getViewModel() - val initialCount = viewModel.uiState.value.visibleItemCount - - viewModel.uiState.value.increaseVisibleItemCount() - - val state = viewModel.uiState.value - assertEquals(initialCount + 10, state.visibleItemCount) - } - - @Test - fun `Multiple increaseVisibleItemCount calls accumulate`() { - val viewModel = getViewModel() - - viewModel.uiState.value.increaseVisibleItemCount() - viewModel.uiState.value.increaseVisibleItemCount() - - val state = viewModel.uiState.value - assertEquals(30, state.visibleItemCount) - } - - @Test - fun `Refresh calls repository with forceNetwork true`() { - val viewModel = getViewModel() - - viewModel.uiState.value.loadingState.onRefresh() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { repository.getPrograms(true) } - } - - @Test - fun `Refresh updates programs list`() { - val viewModel = getViewModel() - val updatedPrograms = listOf( - createTestProgram( - id = "program5", - name = "New Program", - requirements = listOf( - createTestProgramRequirement(courseId = 5L, progress = 0.0) - ) - ) - ) - coEvery { repository.getPrograms(true) } returns updatedPrograms - coEvery { repository.getCoursesById(any(), any()) } returns listOf( - createTestCourse(courseId = 5L, courseName = "New Course") - ) - - viewModel.uiState.value.loadingState.onRefresh() - testDispatcher.scheduler.advanceUntilIdle() - - val state = viewModel.uiState.value - assertFalse(state.loadingState.isRefreshing) - assertEquals(1, state.filteredPrograms.size) - assertEquals("New Program", state.filteredPrograms[0].programName) - } - - @Test - fun `Refresh on error shows snackbar message`() { - val viewModel = getViewModel() - every { context.getString(R.string.learnProgramListFailedToLoadMessage) } returns "Failed to load" - coEvery { repository.getPrograms(true) } throws Exception("Network error") - - viewModel.uiState.value.loadingState.onRefresh() - testDispatcher.scheduler.advanceUntilIdle() - - val state = viewModel.uiState.value - assertFalse(state.loadingState.isRefreshing) - assertTrue(state.loadingState.snackbarMessage != null) - } - - @Test - fun `Dismiss snackbar clears snackbar message`() { - val viewModel = getViewModel() - every { context.getString(R.string.learnProgramListFailedToLoadMessage) } returns "Failed to load" - coEvery { repository.getPrograms(true) } throws Exception("Network error") - viewModel.uiState.value.loadingState.onRefresh() - testDispatcher.scheduler.advanceUntilIdle() - - viewModel.uiState.value.loadingState.onSnackbarDismiss() - - val state = viewModel.uiState.value - assertNull(state.loadingState.snackbarMessage) - } - - @Test - fun `Programs are mapped correctly to LearnProgramState`() { - val viewModel = getViewModel() - - val state = viewModel.uiState.value - val firstProgram = state.filteredPrograms[0] - assertEquals("program1", firstProgram.programId) - assertEquals("Software Engineering", firstProgram.programName) - assertEquals(0.0, firstProgram.programProgress) - } - - @Test - fun `Program chips are created with correct course count and duration`() { - val viewModel = getViewModel() - - val state = viewModel.uiState.value - val firstProgram = state.filteredPrograms[0] - assertTrue(firstProgram.programChips.isNotEmpty()) - } - - @Test - fun `Changing filter resets to filtered view of all loaded programs`() { - val viewModel = getViewModel() - viewModel.uiState.value.updateSearchQuery(TextFieldValue("engineering")) - - viewModel.uiState.value.updateFilterValue(LearnProgramFilterOption.InProgress) - - val state = viewModel.uiState.value - assertEquals(0, state.filteredPrograms.size) - } - - private fun getViewModel(): LearnProgramListViewModel { - return LearnProgramListViewModel(context, resources, repository) - } - - private fun createTestProgram( - id: String = "testProgram", - name: String = "Test Program", - requirements: List = emptyList() - ): Program = Program( - id = id, - name = name, - description = "Test description", - startDate = null, - endDate = null, - variant = ProgramVariantType.LINEAR, - courseCompletionCount = null, - sortedRequirements = requirements - ) - - private fun createTestProgramRequirement( - courseId: Long = 1L, - progress: Double = 0.0 - ): ProgramRequirement = ProgramRequirement( - id = "requirement$courseId", - progressId = "progress$courseId", - courseId = courseId, - required = true, - progress = progress, - enrollmentStatus = null - ) - - private fun createTestCourse( - courseId: Long = 1L, - courseName: String = "Test Course" - ): CourseWithModuleItemDurations = CourseWithModuleItemDurations( - courseId = courseId, - courseName = courseName, - moduleItemsDuration = listOf("PT1H"), - startDate = null, - endDate = null - ) -} diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginFindSchoolActivity.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginFindSchoolActivity.kt index e3e2bc7cbf..214a8b1d51 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginFindSchoolActivity.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginFindSchoolActivity.kt @@ -16,17 +16,14 @@ */ package com.instructure.loginapi.login.activities -import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo -import android.net.Uri import android.os.Bundle import android.os.Handler import android.text.Editable import android.text.TextUtils import android.text.TextWatcher import android.view.LayoutInflater -import android.view.View import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityManager import android.widget.ImageView @@ -35,15 +32,15 @@ import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.instructure.canvasapi2.StatusCallback -import com.instructure.canvasapi2.managers.AccountDomainManager import com.instructure.canvasapi2.models.AccountDomain import com.instructure.canvasapi2.utils.APIHelper -import com.instructure.canvasapi2.utils.ApiType -import com.instructure.canvasapi2.utils.LinkHeaders import com.instructure.loginapi.login.R import com.instructure.loginapi.login.adapter.DomainAdapter import com.instructure.loginapi.login.databinding.ActivityFindSchoolBinding @@ -51,53 +48,57 @@ import com.instructure.loginapi.login.dialog.NoInternetConnectionDialog import com.instructure.loginapi.login.util.Const import com.instructure.pandautils.base.BaseCanvasActivity import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.domain.usecase.accountdomain.SearchAccountDomainUseCase import com.instructure.pandautils.utils.ColorUtils import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyBottomSystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.setupAsBackButton -import retrofit2.Response +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import java.util.Locale import java.util.regex.Pattern +import javax.inject.Inject +@AndroidEntryPoint abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { + @Inject + lateinit var searchAccountDomainUseCase: SearchAccountDomainUseCase + private val binding by viewBinding(ActivityFindSchoolBinding::inflate) - private var mDomainAdapter: DomainAdapter? = null - private var mNextActionButton: TextView? = null - private val mDelayFetchAccountHandler = Handler() - protected var mWhatsYourSchoolName: TextView? = null - private var mLoginFlowLogout: TextView? = null + private var domainAdapter: DomainAdapter? = null + private var nextActionButton: TextView? = null + private val delayFetchAccountHandler = Handler() + protected var whatsYourSchoolName: TextView? = null + private var loginFlowLogout: TextView? = null /** * Worker thread for fetching account domains. */ - private val mFetchAccountsWorker = Runnable { + private val fetchAccountsWorker = Runnable { val query = binding.domainInput.text.toString() - AccountDomainManager.searchAccounts(query, mAccountDomainCallback) - } - - private val mAccountDomainCallback = object : StatusCallback>() { - - override fun onResponse(response: Response>, linkHeaders: LinkHeaders, type: ApiType) { - if (type.isCache) return - - val domains = response.body()?.toMutableList() ?: mutableListOf() - - val isDebuggable = 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE - - if (isDebuggable) { - // Put these domains first - domains.add(0, createAccountForDebugging("mobiledev.instructure.com")) - domains.add(1, createAccountForDebugging("mobiledev.beta.instructure.com")) - domains.add(2, createAccountForDebugging("mobileqa.instructure.com")) - domains.add(3, createAccountForDebugging("mobileqat.instructure.com")) - domains.add(4, createAccountForDebugging("clare.instructure.com")) - domains.add(5, createAccountForDebugging("mobileqa.beta.instructure.com")) - } + lifecycleScope.launch { + searchAccountDomainUseCase(SearchAccountDomainUseCase.Params(query)).let { result -> + val domains = result.toMutableList() + + val isDebuggable = 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE + + if (isDebuggable) { + // Put these domains first + domains.add(0, createAccountForDebugging("mobiledev.instructure.com")) + domains.add(1, createAccountForDebugging("mobiledev.beta.instructure.com")) + domains.add(2, createAccountForDebugging("mobileqa.instructure.com")) + domains.add(3, createAccountForDebugging("mobileqat.instructure.com")) + domains.add(4, createAccountForDebugging("clare.instructure.com")) + domains.add(5, createAccountForDebugging("mobileqa.beta.instructure.com")) + } - if (mDomainAdapter != null) { - mDomainAdapter!!.setItems(domains) - mDomainAdapter!!.filter.filter(binding.domainInput!!.text.toString()) + if (domainAdapter != null) { + domainAdapter!!.setItems(domains) + domainAdapter!!.filter.filter(binding.domainInput.text.toString()) + } } } } @@ -110,14 +111,31 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) + setupWindowInsets() bindViews() applyTheme() } + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.setPadding( + insets.left, + 0, + insets.right, + 0 + ) + windowInsets + } + } + private fun bindViews() = with(binding) { - mWhatsYourSchoolName = findViewById(R.id.whatsYourSchoolName) - mLoginFlowLogout = findViewById(R.id.loginFlowLogout) + this@BaseLoginFindSchoolActivity.whatsYourSchoolName = findViewById(R.id.whatsYourSchoolName) + this@BaseLoginFindSchoolActivity.loginFlowLogout = findViewById(R.id.loginFlowLogout) toolbar.apply { + applyTopSystemBarInsets() navigationIcon?.isAutoMirrored = true setupAsBackButton { finish() } inflateMenu(R.menu.menu_next) @@ -135,8 +153,8 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { }) } - val a11yManager = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager - if (a11yManager != null && (a11yManager.isEnabled || a11yManager.isTouchExplorationEnabled)) { + val a11yManager = getSystemService(ACCESSIBILITY_SERVICE) as AccessibilityManager + if (a11yManager.isEnabled || a11yManager.isTouchExplorationEnabled) { toolbar.isFocusable = true toolbar.isFocusableInTouchMode = true toolbar.postDelayed({ @@ -145,9 +163,14 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { }, 500) } - mNextActionButton = findViewById(R.id.next) - mNextActionButton!!.isEnabled = false - mNextActionButton!!.setTextColor(ContextCompat.getColor(this@BaseLoginFindSchoolActivity, R.color.backgroundMedium)) + nextActionButton = findViewById(R.id.next) + nextActionButton!!.isEnabled = false + nextActionButton!!.setTextColor( + ContextCompat.getColor( + this@BaseLoginFindSchoolActivity, + R.color.backgroundMedium + ) + ) domainInput.requestFocus() domainInput.setOnEditorActionListener { _, _, _ -> @@ -161,26 +184,32 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable) { - if (mDomainAdapter != null) { - mDomainAdapter!!.filter.filter(s) + if (domainAdapter != null) { + domainAdapter!!.filter.filter(s) fetchAccountDomains() } - if (mNextActionButton != null) { + if (nextActionButton != null) { if (TextUtils.isEmpty(s.toString())) { - mNextActionButton!!.isEnabled = false - mNextActionButton!!.setTextColor(ContextCompat.getColor( - this@BaseLoginFindSchoolActivity, R.color.backgroundMedium)) + nextActionButton!!.isEnabled = false + nextActionButton!!.setTextColor( + ContextCompat.getColor( + this@BaseLoginFindSchoolActivity, R.color.backgroundMedium + ) + ) } else { - mNextActionButton!!.isEnabled = true - mNextActionButton!!.setTextColor(ContextCompat.getColor( - this@BaseLoginFindSchoolActivity, R.color.textInfo)) + nextActionButton!!.isEnabled = true + nextActionButton!!.setTextColor( + ContextCompat.getColor( + this@BaseLoginFindSchoolActivity, R.color.textInfo + ) + ) } } } }) - mDomainAdapter = DomainAdapter(object : DomainAdapter.DomainEvents { + domainAdapter = DomainAdapter(object : DomainAdapter.DomainEvents { override fun onDomainClick(account: AccountDomain) { domainInput.setText(account.domain) domainInput.setSelection(domainInput.text.length) @@ -188,15 +217,23 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { } override fun onHelpClick() { - val webHelpIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Const.FIND_SCHOOL_HELP_URL)) + val webHelpIntent = + Intent(Intent.ACTION_VIEW, Const.FIND_SCHOOL_HELP_URL.toUri()) startActivity(webHelpIntent) } }) val recyclerView = findViewById(R.id.findSchoolRecyclerView) - recyclerView.addItemDecoration(DividerItemDecoration(this@BaseLoginFindSchoolActivity, RecyclerView.VERTICAL)) - recyclerView.layoutManager = LinearLayoutManager(this@BaseLoginFindSchoolActivity, RecyclerView.VERTICAL, false) - recyclerView.adapter = mDomainAdapter + recyclerView.addItemDecoration( + DividerItemDecoration( + this@BaseLoginFindSchoolActivity, + RecyclerView.VERTICAL + ) + ) + recyclerView.layoutManager = + LinearLayoutManager(this@BaseLoginFindSchoolActivity, RecyclerView.VERTICAL, false) + recyclerView.adapter = domainAdapter + recyclerView.applyBottomSystemBarInsets() } /** @@ -204,17 +241,6 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { */ protected fun logout() {} - /** - * Shows a logout button. Calls from click return to a logout() - * @param show a value to indicate if the logout button should be shown. - */ - protected fun showLogout(show: Boolean) { - mLoginFlowLogout!!.visibility = if (show) View.VISIBLE else View.GONE - if (show) { - mLoginFlowLogout!!.setOnClickListener { logout() } - } - } - private fun validateDomain(accountDomain: AccountDomain) { var url: String? = accountDomain.domain!!.lowercase(Locale.getDefault()).replace(" ", "") @@ -224,7 +250,7 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { } //remove invalid characters at the end of the domain - val pattern = Pattern.compile("(.*)([a-zA-Z0-9]){1}") + val pattern = Pattern.compile("(.*)([a-zA-Z0-9])") val matcher = pattern.matcher(url) if (matcher.find()) { url = matcher.group() @@ -241,7 +267,7 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { } //Get just the host. - val uri = Uri.parse(url) + val uri = url.toUri() url = uri.host //Strip off www. if they typed it. @@ -272,8 +298,8 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { * Handles fetching account domains. Uses a worker runnable and handler to cancel fetching too often. */ private fun fetchAccountDomains() { - mDelayFetchAccountHandler.removeCallbacks(mFetchAccountsWorker) - mDelayFetchAccountHandler.postDelayed(mFetchAccountsWorker, 500) + delayFetchAccountHandler.removeCallbacks(fetchAccountsWorker) + delayFetchAccountHandler.postDelayed(fetchAccountsWorker, 500) } private fun createAccountForDebugging(domain: String): AccountDomain { @@ -285,9 +311,7 @@ abstract class BaseLoginFindSchoolActivity : BaseCanvasActivity() { } override fun onDestroy() { - if (mDelayFetchAccountHandler != null && mFetchAccountsWorker != null) { - mDelayFetchAccountHandler.removeCallbacks(mFetchAccountsWorker) - } + delayFetchAccountHandler.removeCallbacks(fetchAccountsWorker) super.onDestroy() } diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginLandingPageActivity.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginLandingPageActivity.kt index 5d5fb8989a..a543fc9180 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginLandingPageActivity.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginLandingPageActivity.kt @@ -60,6 +60,9 @@ import com.instructure.loginapi.login.viewmodel.LoginViewModel import com.instructure.pandautils.base.BaseCanvasActivity import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.* +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import java.util.* import javax.inject.Inject @@ -107,6 +110,19 @@ abstract class BaseLoginLandingPageActivity : BaseCanvasActivity() { } private fun bindViews() = with(binding) { + ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.updatePadding( + left = insets.left, + top = insets.top, + right = insets.right, + bottom = insets.bottom + ) + WindowInsetsCompat.CONSUMED + } + canvasNetwork.onClick { if (APIHelper.hasNetworkConnection()) { val intent = beginCanvasNetworkFlow(URL_CANVAS_NETWORK) diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginSignInActivity.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginSignInActivity.kt index d083a2811c..4a5fb694f7 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginSignInActivity.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginSignInActivity.kt @@ -41,6 +41,8 @@ import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.instructure.canvasapi2.RequestInterceptor.Companion.acceptedLanguageString import com.instructure.canvasapi2.StatusCallback import com.instructure.canvasapi2.TokenRefreshState @@ -87,8 +89,10 @@ import com.instructure.loginapi.login.util.SavedLoginInfo import com.instructure.loginapi.login.viewmodel.LoginViewModel import com.instructure.pandautils.base.BaseCanvasActivity import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.pandautils.utils.Utils import com.instructure.pandautils.utils.ViewStyler.themeStatusBar +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.setGone import com.instructure.pandautils.utils.setVisible import com.instructure.pandautils.utils.setupAsBackButton @@ -135,7 +139,9 @@ abstract class BaseLoginSignInActivity : BaseCanvasActivity(), OnAuthenticationS override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + EdgeToEdgeHelper.enableEdgeToEdge(this) setContentView(binding.root) + setupWindowInsets() canvasLogin = intent!!.extras!!.getInt(Const.CANVAS_LOGIN, 0) setupViews() applyTheme() @@ -163,9 +169,25 @@ abstract class BaseLoginSignInActivity : BaseCanvasActivity(), OnAuthenticationS } } + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.setPadding( + insets.left, + 0, + insets.right, + insets.bottom + ) + windowInsets + } + } + @SuppressLint("SetJavaScriptEnabled") private fun setupViews() { val toolbar = findViewById(R.id.toolbar) + toolbar.applyTopSystemBarInsets() toolbar.title = accountDomain.domain toolbar.navigationIcon?.isAutoMirrored = true toolbar.setupAsBackButton { diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/LoginWithQRActivity.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/LoginWithQRActivity.kt index 5d69a26991..0e487687e1 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/LoginWithQRActivity.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/LoginWithQRActivity.kt @@ -31,8 +31,13 @@ import com.instructure.loginapi.login.dialog.NoInternetConnectionDialog import com.instructure.loginapi.login.util.QRLogin import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.setMenu import com.instructure.pandautils.utils.setupAsBackButton +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import com.instructure.pandautils.utils.EdgeToEdgeHelper abstract class LoginWithQRActivity : BaseCanvasActivity() { @@ -42,18 +47,34 @@ abstract class LoginWithQRActivity : BaseCanvasActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + EdgeToEdgeHelper.enableEdgeToEdge(this) setContentView(binding.root) + setupWindowInsets() bindViews() } + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.updatePadding( + left = insets.left, + right = insets.right + ) + windowInsets + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) // Capture the results from the QR scanner if (result?.contents != null) { val loginUri = Uri.parse(result.contents) - if(QRLogin.verifySSOLoginUri(loginUri)) { + if (QRLogin.verifySSOLoginUri(loginUri)) { // Valid link, let's launch it launchApplicationWithQRLogin(loginUri) } else { @@ -66,6 +87,7 @@ abstract class LoginWithQRActivity : BaseCanvasActivity() { private fun bindViews() = with(binding) { toolbar.apply { + applyTopSystemBarInsets() title = getString(R.string.locateQRCode) setupAsBackButton { finish() } navigationIcon?.isAutoMirrored = true @@ -89,5 +111,12 @@ abstract class LoginWithQRActivity : BaseCanvasActivity() { val nextText: TextView = findViewById(R.id.next) nextText.setTextColor(ContextCompat.getColor(this@LoginWithQRActivity, R.color.textInfo)) + + // Apply bottom insets to the image (last element in scrollable content) + ViewCompat.setOnApplyWindowInsetsListener(image) { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = insets.bottom) + windowInsets + } } } \ No newline at end of file diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/acceptableusepolicy/AcceptableUsePolicyActivity.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/acceptableusepolicy/AcceptableUsePolicyActivity.kt index bb6643d739..79f38c08d3 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/acceptableusepolicy/AcceptableUsePolicyActivity.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/features/acceptableusepolicy/AcceptableUsePolicyActivity.kt @@ -22,8 +22,11 @@ import com.google.android.material.snackbar.Snackbar import com.instructure.loginapi.login.R import com.instructure.loginapi.login.databinding.ActivityAcceptableUsePolicyBinding import com.instructure.pandautils.base.BaseCanvasActivity +import com.instructure.pandautils.utils.EdgeToEdgeHelper import com.instructure.pandautils.utils.ToolbarColorizeHelper import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.applySystemBarInsets +import com.instructure.pandautils.utils.applyTopSystemBarInsets import com.instructure.pandautils.utils.setMenu import com.instructure.pandautils.utils.setupAsCloseButton import com.instructure.pandautils.utils.withRequireNetwork @@ -42,11 +45,14 @@ class AcceptableUsePolicyActivity : BaseCanvasActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + EdgeToEdgeHelper.enableEdgeToEdge(this) binding = ActivityAcceptableUsePolicyBinding.inflate(layoutInflater) binding.viewModel = viewModel binding.lifecycleOwner = this setContentView(binding.root) + binding.root.applySystemBarInsets() + binding.toolbar.applyTopSystemBarInsets() binding.toolbar.setTitle(R.string.acceptableUsePolicyTitle) binding.toolbar.setupAsCloseButton { router.logout() diff --git a/libs/login-api-2/src/main/res/layout-w720dp/activity_find_school.xml b/libs/login-api-2/src/main/res/layout-w720dp/activity_find_school.xml index aaa337efa2..559035b615 100644 --- a/libs/login-api-2/src/main/res/layout-w720dp/activity_find_school.xml +++ b/libs/login-api-2/src/main/res/layout-w720dp/activity_find_school.xml @@ -25,7 +25,8 @@ diff --git a/libs/login-api-2/src/main/res/layout/activity_find_school.xml b/libs/login-api-2/src/main/res/layout/activity_find_school.xml index 6560828757..f4593af659 100644 --- a/libs/login-api-2/src/main/res/layout/activity_find_school.xml +++ b/libs/login-api-2/src/main/res/layout/activity_find_school.xml @@ -27,7 +27,8 @@ + android:layout_weight="1" + android:clipToPadding="false"/> true @color/darkStatusBarColor @font/lato_font_family - true diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/accountdomain/AccountDomainRepositoryImplTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/accountdomain/AccountDomainRepositoryImplTest.kt new file mode 100644 index 0000000000..5b1003b088 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/accountdomain/AccountDomainRepositoryImplTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2026 - present Instructure, 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. + */ +package com.instructure.pandautils.data.repository.accountdomain + +import com.instructure.canvasapi2.apis.AccountDomainInterface +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.AccountDomain +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.canvasapi2.utils.LinkHeaders +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AccountDomainRepositoryImplTest { + + private val api: AccountDomainInterface = mockk(relaxed = true) + private lateinit var repository: AccountDomainRepositoryImpl + + @Before + fun setUp() { + repository = AccountDomainRepositoryImpl(api) + } + + @Test + fun `search returns account domains from API`() = runTest { + val domains = listOf( + AccountDomain(domain = "school1.instructure.com", name = "School 1"), + AccountDomain(domain = "school2.instructure.com", name = "School 2") + ) + + coEvery { api.campusSearch("school", any()) } returns DataResult.Success(domains) + + val result = repository.search("school") + + assertEquals(2, result.size) + assertEquals("school1.instructure.com", result[0].domain) + assertEquals("school2.instructure.com", result[1].domain) + } + + @Test + fun `search returns empty list when API fails`() = runTest { + coEvery { api.campusSearch("school", any()) } returns DataResult.Fail() + + val result = repository.search("school") + + assertTrue(result.isEmpty()) + } + + @Test + fun `search returns empty list when API returns no results`() = runTest { + coEvery { api.campusSearch("xyz", any()) } returns DataResult.Success(emptyList()) + + val result = repository.search("xyz") + + assertTrue(result.isEmpty()) + } + + @Test + fun `search passes correct RestParams`() = runTest { + val paramsSlot = slot() + + coEvery { api.campusSearch(any(), capture(paramsSlot)) } returns DataResult.Success(emptyList()) + + repository.search("school") + + assertTrue(paramsSlot.captured.usePerPageQueryParam) + assertTrue(paramsSlot.captured.isForceReadFromNetwork) + } + + @Test + fun `search passes query to API`() = runTest { + coEvery { api.campusSearch(any(), any()) } returns DataResult.Success(emptyList()) + + repository.search("test query") + + coVerify { api.campusSearch("test query", any()) } + } + + @Test + fun `search depaginates results across multiple pages`() = runTest { + val page1 = listOf(AccountDomain(domain = "school1.instructure.com", name = "School 1")) + val page2 = listOf(AccountDomain(domain = "school2.instructure.com", name = "School 2")) + val page3 = listOf(AccountDomain(domain = "school3.instructure.com", name = "School 3")) + + coEvery { api.campusSearch("school", any()) } returns DataResult.Success( + page1, + linkHeaders = LinkHeaders(nextUrl = "page2") + ) + coEvery { api.next("page2", any()) } returns DataResult.Success( + page2, + linkHeaders = LinkHeaders(nextUrl = "page3") + ) + coEvery { api.next("page3", any()) } returns DataResult.Success(page3) + + val result = repository.search("school") + + assertEquals(3, result.size) + assertEquals("school1.instructure.com", result[0].domain) + assertEquals("school2.instructure.com", result[1].domain) + assertEquals("school3.instructure.com", result[2].domain) + } + + @Test + fun `search stops depagination when next page fails`() = runTest { + val page1 = listOf(AccountDomain(domain = "school1.instructure.com", name = "School 1")) + + coEvery { api.campusSearch("school", any()) } returns DataResult.Success( + page1, + linkHeaders = LinkHeaders(nextUrl = "page2") + ) + coEvery { api.next("page2", any()) } returns DataResult.Fail() + + val result = repository.search("school") + + assertEquals(1, result.size) + assertEquals("school1.instructure.com", result[0].domain) + coVerify(exactly = 0) { api.next("page3", any()) } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/accountdomain/SearchAccountDomainUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/accountdomain/SearchAccountDomainUseCaseTest.kt new file mode 100644 index 0000000000..23ab04f77c --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/accountdomain/SearchAccountDomainUseCaseTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 - present Instructure, 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. + */ +package com.instructure.pandautils.domain.usecase.accountdomain + +import com.instructure.canvasapi2.models.AccountDomain +import com.instructure.pandautils.data.repository.accountdomain.AccountDomainRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SearchAccountDomainUseCaseTest { + + private val repository: AccountDomainRepository = mockk(relaxed = true) + private val useCase = SearchAccountDomainUseCase(repository) + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `execute returns domains from repository`() = runTest { + val domains = listOf( + AccountDomain(domain = "school.instructure.com", name = "School"), + AccountDomain(domain = "university.instructure.com", name = "University") + ) + + coEvery { repository.search("school") } returns domains + + val result = useCase(SearchAccountDomainUseCase.Params("school")) + + assertEquals(2, result.size) + assertEquals("school.instructure.com", result[0].domain) + assertEquals("university.instructure.com", result[1].domain) + } + + @Test + fun `execute returns empty list when query is shorter than 3 characters`() = runTest { + val result = useCase(SearchAccountDomainUseCase.Params("ab")) + + assertTrue(result.isEmpty()) + coVerify(exactly = 0) { repository.search(any()) } + } + + @Test + fun `execute returns empty list for single character query`() = runTest { + val result = useCase(SearchAccountDomainUseCase.Params("a")) + + assertTrue(result.isEmpty()) + coVerify(exactly = 0) { repository.search(any()) } + } + + @Test + fun `execute returns empty list for empty query`() = runTest { + val result = useCase(SearchAccountDomainUseCase.Params("")) + + assertTrue(result.isEmpty()) + coVerify(exactly = 0) { repository.search(any()) } + } + + @Test + fun `execute calls repository when query is exactly 3 characters`() = runTest { + coEvery { repository.search("abc") } returns emptyList() + + useCase(SearchAccountDomainUseCase.Params("abc")) + + coVerify(exactly = 1) { repository.search("abc") } + } + + @Test + fun `execute passes query to repository`() = runTest { + coEvery { repository.search(any()) } returns emptyList() + + useCase(SearchAccountDomainUseCase.Params("test university")) + + coVerify(exactly = 1) { repository.search("test university") } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendar/CalendarViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendar/CalendarViewModelTest.kt index e96c14f53f..c3a00e62d4 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendar/CalendarViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendar/CalendarViewModelTest.kt @@ -110,7 +110,7 @@ class CalendarViewModelTest { "${args[0]} ${args[1]} - ${args[2]}" } - every { context.getString(eq(R.string.courseToDo), any()) } answers { + every { context.getString(eq(R.string.courseToDoNew), any()) } answers { val args = secondArg>() "${args[0]} To Do" } @@ -129,7 +129,7 @@ class CalendarViewModelTest { every { context.getString(R.string.calendarEventMissing) } returns "missing" every { context.getString(R.string.calendarEventGraded) } returns "graded" every { context.getString(R.string.calendarEventSubmitted) } returns "needs grading" - every { context.getString(R.string.userCalendarToDo) } returns "To Do" + every { context.getString(R.string.userCalendarToDoNew) } returns "To Do" every { context.getString(eq(R.string.calendarEventPoints), any()) } answers { val args = secondArg>() "${args[0]} pts" @@ -936,24 +936,31 @@ class CalendarViewModelTest { 3, PlannableType.ASSIGNMENT, createDate(2023, 4, 20, 12), - submissionState = SubmissionState(graded = true) + submissionState = SubmissionState(graded = true, postedAt = Date()) ), createPlannerItem( - 2, + 1, 4, PlannableType.ASSIGNMENT, createDate(2023, 4, 20, 12), - submissionState = SubmissionState(needsGrading = true) + submissionState = SubmissionState(graded = true, postedAt = null) ), createPlannerItem( 2, 5, PlannableType.ASSIGNMENT, createDate(2023, 4, 20, 12), + submissionState = SubmissionState(needsGrading = true) + ), + createPlannerItem( + 2, + 6, + PlannableType.ASSIGNMENT, + createDate(2023, 4, 20, 12), pointsPossible = 10.0, submissionState = SubmissionState() ), - createPlannerItem(2, 6, PlannableType.ASSIGNMENT, createDate(2023, 4, 20, 12), submissionState = SubmissionState()), + createPlannerItem(2, 7, PlannableType.ASSIGNMENT, createDate(2023, 4, 20, 12), submissionState = SubmissionState()), ) coEvery { calendarRepository.getPlannerItems(any(), any(), any(), any()) } returns events initViewModel() @@ -963,8 +970,9 @@ class CalendarViewModelTest { assertEquals("missing", currentPageEvents[1].status) assertEquals("graded", currentPageEvents[2].status) assertEquals("needs grading", currentPageEvents[3].status) - assertEquals("10 pts", currentPageEvents[4].status) - assertNull(currentPageEvents[5].status) + assertEquals("needs grading", currentPageEvents[4].status) + assertEquals("10 pts", currentPageEvents[5].status) + assertNull(currentPageEvents[6].status) } @Test diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendartodo/createupdate/CreateUpdateToDoViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendartodo/createupdate/CreateUpdateToDoViewModelTest.kt index e6be65efcc..2a6d7daa4d 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendartodo/createupdate/CreateUpdateToDoViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendartodo/createupdate/CreateUpdateToDoViewModelTest.kt @@ -221,7 +221,7 @@ class CreateUpdateToDoViewModelTest { @Test fun `Save ToDo failed`() { - every { resources.getString(R.string.todoSaveErrorMessage) } returns "Failed to save ToDo" + every { resources.getString(R.string.todoSaveErrorMessageNew) } returns "Failed to save ToDo" coEvery { repository.createToDo(any(), any(), any(), any()) } throws Exception() createViewModel() diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendartodo/details/ToDoViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendartodo/details/ToDoViewModelTest.kt index 75a957c84a..1daa04bf85 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendartodo/details/ToDoViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendartodo/details/ToDoViewModelTest.kt @@ -171,7 +171,7 @@ class ToDoViewModelTest { @Test fun `Error deleting ToDo`() = runTest { - every { context.getString(R.string.todoDeleteErrorMessage) } returns "Error deleting to do" + every { context.getString(R.string.todoDeleteErrorMessageNew) } returns "Error deleting to do" coEvery { toDoRepository.deletePlannerNote(any()) } throws Exception() viewModel.handleAction(ToDoAction.DeleteToDo) diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt index 4955581882..e8020c3b00 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt @@ -1027,7 +1027,7 @@ class ScheduleViewModelTest { every { resources.getString(R.string.tomorrow) } returns "Tomorrow" every { resources.getString(R.string.yesterday) } returns "Yesterday" every { resources.getString(R.string.today) } returns "Today" - every { resources.getString(R.string.schedule_todo_title) } returns "To Do" + every { resources.getString(R.string.schedule_todo_title_new) } returns "To Do" every { resources.getQuantityString(R.plurals.schedule_tag_replies, 2, 2) } returns "2 Replies" every { resources.getQuantityString(R.plurals.schedule_tag_replies, 1, 1) } returns "1 Reply" every { resources.getQuantityString(R.plurals.schedule_points, 20, "20") } returns "20 pts" diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/file/upload/FileUploadViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/file/upload/FileUploadViewModelTest.kt index 0fa5fcd18d..e361ac1cc9 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/file/upload/FileUploadViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/file/upload/FileUploadViewModelTest.kt @@ -27,6 +27,7 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.postmodels.FileSubmitObject import com.instructure.pandautils.R +import com.instructure.pandautils.features.file.upload.scanner.DocumentScannerManager import com.instructure.pandautils.room.appdatabase.daos.FileUploadInputDao import io.mockk.every import io.mockk.mockk @@ -40,6 +41,8 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -59,6 +62,7 @@ class FileUploadViewModelTest { private val fileUploadUtilsHelper: FileUploadUtilsHelper = mockk(relaxed = true) private val workManager: WorkManager = mockk(relaxed = true) private val fileUploadInputDao: FileUploadInputDao = mockk(relaxed = true) + private val documentScannerManager: DocumentScannerManager = mockk(relaxed = true) @Before fun setUp() { @@ -345,7 +349,46 @@ class FileUploadViewModelTest { return FileSubmitObject(fileName, fileSize, "file", "/$fileName") } + @Test + fun `Scanner clicked emits LaunchScanner action`() { + val viewModel = createViewModel() + viewModel.events.observe(lifecycleOwner) {} + + viewModel.onScannerClicked() + + assertEquals(FileUploadAction.LaunchScanner, viewModel.events.value?.getContentIfNotHandled()) + } + + @Test + fun `Scanner clicked shows toast when one file only and file exists`() { + val uri: Uri = mockk(relaxed = true) + val viewModel = createViewModel() + val course = createCourse(1L, "Course 1") + val assignment = createAssignment(1L, "Assignment 1", 1L, listOf("pdf")) + val submitObject = createSubmitObject("test.pdf") + + every { fileUploadUtilsHelper.getFileSubmitObjectFromInputStream(any(), any(), any()) } returns submitObject + + viewModel.setData(assignment, arrayListOf(uri), FileUploadType.QUIZ, course, -1L, -1L, -1, -1L, -1L, null) + + viewModel.events.observe(lifecycleOwner) {} + + viewModel.onScannerClicked() + assertEquals(FileUploadAction.ShowToast("This submission only accepts one file upload"), viewModel.events.value?.getContentIfNotHandled()) + } + + @Test + fun `Scanner available delegates to DocumentScannerManager`() { + every { documentScannerManager.isDeviceSupported() } returns true + val viewModel = createViewModel() + assertTrue(viewModel.scannerAvailable) + + every { documentScannerManager.isDeviceSupported() } returns false + val viewModel2 = createViewModel() + assertFalse(viewModel2.scannerAvailable) + } + private fun createViewModel(): FileUploadDialogViewModel { - return FileUploadDialogViewModel(fileUploadUtilsHelper, resources, workManager, fileUploadInputDao) + return FileUploadDialogViewModel(fileUploadUtilsHelper, resources, workManager, fileUploadInputDao, documentScannerManager) } } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/file/upload/scanner/DocumentScannerManagerTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/file/upload/scanner/DocumentScannerManagerTest.kt new file mode 100644 index 0000000000..30e47cb468 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/file/upload/scanner/DocumentScannerManagerTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 - present Instructure, 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. + */ +package com.instructure.pandautils.features.file.upload.scanner + +import android.app.ActivityManager +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class DocumentScannerManagerTest { + + private val context: Context = mockk(relaxed = true) + private val activityManager: ActivityManager = mockk(relaxed = true) + + private lateinit var documentScannerManager: DocumentScannerManager + + @Before + fun setup() { + every { context.getSystemService(Context.ACTIVITY_SERVICE) } returns activityManager + documentScannerManager = DocumentScannerManager(context) + } + + @Test + fun `isDeviceSupported returns true when RAM is above threshold`() { + val memoryInfo = ActivityManager.MemoryInfo() + memoryInfo.totalMem = (2L * 1024 * 1024 * 1024) // 2 GB + val slot = slot() + every { activityManager.getMemoryInfo(capture(slot)) } answers { + slot.captured.totalMem = memoryInfo.totalMem + } + + assertTrue(documentScannerManager.isDeviceSupported()) + } + + @Test + fun `isDeviceSupported returns false when RAM is below threshold`() { + val memoryInfo = ActivityManager.MemoryInfo() + memoryInfo.totalMem = (1L * 1024 * 1024 * 1024) // 1 GB + val slot = slot() + every { activityManager.getMemoryInfo(capture(slot)) } answers { + slot.captured.totalMem = memoryInfo.totalMem + } + + assertFalse(documentScannerManager.isDeviceSupported()) + } + + @Test + fun `isDeviceSupported returns true when RAM is just above threshold`() { + val slot = slot() + every { activityManager.getMemoryInfo(capture(slot)) } answers { + slot.captured.totalMem = (1.8 * 1024 * 1024 * 1024).toLong() + } + + assertTrue(documentScannerManager.isDeviceSupported()) + } + + @Test + fun `generateFileName follows expected format`() { + val fileName = documentScannerManager.generateFileName() + + assertTrue(fileName.startsWith("Scanned_Document_")) + assertTrue(fileName.endsWith(".pdf")) + } + + @Test + fun `handleScanResultFromIntent returns empty result for null intent`() { + val result = documentScannerManager.handleScanResultFromIntent(null) + + assertNull(result.pdfUri) + assertTrue(result.pageUris.isEmpty()) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModelTest.kt index 6a9ee83c1e..dabcec9f9e 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/inbox/details/InboxDetailsViewModelTest.kt @@ -17,6 +17,7 @@ package com.instructure.pandautils.features.inbox.details import android.content.Context import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.apis.InboxApi import com.instructure.canvasapi2.models.Attachment import com.instructure.canvasapi2.models.BasicUser import com.instructure.canvasapi2.models.Conversation @@ -72,6 +73,7 @@ class InboxDetailsViewModelTest { coEvery { inboxDetailsRepository.getConversation(any(), any(), any()) } returns DataResult.Success(conversation) coEvery { savedStateHandle.get(InboxDetailsFragment.CONVERSATION_ID) } returns conversation.id coEvery { savedStateHandle.get(InboxDetailsFragment.UNREAD) } returns false + coEvery { savedStateHandle.get(InboxDetailsFragment.SCOPE) } returns null coEvery { context.getString( com.instructure.pandautils.R.string.inboxForwardSubjectFwPrefix, conversation.subject @@ -444,6 +446,42 @@ class InboxDetailsViewModelTest { coVerify(exactly = 1) { inboxDetailsRepository.updateState(conversation.id, newState) } } + @Test + fun `Test archiving conversation shows archived snackbar`() = runTest { + val viewModel = getViewModel() + val newState = Conversation.WorkflowState.ARCHIVED + val newConversation = conversation.copy(workflowState = newState) + + coEvery { inboxDetailsRepository.updateState(conversation.id, newState) } returns DataResult.Success(newConversation) + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.handleAction(InboxDetailsAction.UpdateState(conversation.id, newState)) + + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationArchived)), events[0]) + } + + @Test + fun `Test unarchiving conversation shows unarchived snackbar`() = runTest { + val viewModel = getViewModel() + val newState = Conversation.WorkflowState.READ + val newConversation = conversation.copy(workflowState = newState) + + coEvery { inboxDetailsRepository.updateState(conversation.id, newState) } returns DataResult.Success(newConversation) + + val events = mutableListOf() + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.handleAction(InboxDetailsAction.UpdateState(conversation.id, newState)) + + assertEquals(InboxDetailsFragmentAction.ShowScreenResult(context.getString(R.string.conversationUnarchived)), events[0]) + } + @Test fun `Test Conversation workflow state update failed`() = runTest { val viewModel = getViewModel() @@ -678,6 +716,24 @@ class InboxDetailsViewModelTest { } } + @Test + fun `Test Sent scope hides archive option`() { + coEvery { savedStateHandle.get(InboxDetailsFragment.SCOPE) } returns InboxApi.Scope.SENT.name + val viewModel = getViewModel() + + assertEquals(false, viewModel.uiState.value.showArchiveOption) + } + + @Test + fun `Test non-Sent scope shows archive option`() { + InboxApi.Scope.entries.filter { it != InboxApi.Scope.SENT }.forEach { scope -> + coEvery { savedStateHandle.get(InboxDetailsFragment.SCOPE) } returns scope.name + val viewModel = getViewModel() + + assertEquals(true, viewModel.uiState.value.showArchiveOption) + } + } + // endregion private fun getViewModel(): InboxDetailsViewModel { diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/StudioOfflineVideoHelperTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/StudioOfflineVideoHelperTest.kt new file mode 100644 index 0000000000..3378684dad --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/StudioOfflineVideoHelperTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2026 - present Instructure, 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. + * + */ + +package com.instructure.pandautils.features.offline.sync + +import android.content.Context +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class StudioOfflineVideoHelperTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private val context: Context = mockk() + private val apiPrefs: ApiPrefs = mockk() + + private lateinit var helper: StudioOfflineVideoHelper + + @Before + fun setup() { + every { context.filesDir } returns tempFolder.root + every { apiPrefs.user } returns User(id = 42) + helper = StudioOfflineVideoHelper(context, apiPrefs) + } + + // region getStudioMediaId + + @Test + fun `getStudioMediaId returns media id from standard LTI launch URL`() { + val url = "https://example.instructuremedia.com/lti/launch?custom_arc_launch_type=embed&custom_arc_media_id=abc-123&custom_arc_start_at=0" + assertEquals("abc-123", helper.getStudioMediaId(url)) + } + + @Test + fun `getStudioMediaId returns media id when it is the first parameter`() { + val url = "https://example.com/lti/launch?custom_arc_media_id=first-param" + assertEquals("first-param", helper.getStudioMediaId(url)) + } + + @Test + fun `getStudioMediaId returns media id when it is the last parameter`() { + val url = "https://example.com/lti/launch?custom_arc_launch_type=embed&custom_arc_media_id=last-param" + assertEquals("last-param", helper.getStudioMediaId(url)) + } + + @Test + fun `getStudioMediaId returns null for URL without media id`() { + val url = "https://example.com/lti/launch?custom_arc_launch_type=embed&custom_arc_start_at=0" + assertNull(helper.getStudioMediaId(url)) + } + + @Test + fun `getStudioMediaId returns null for null input`() { + assertNull(helper.getStudioMediaId(null)) + } + + @Test + fun `getStudioMediaId returns null for empty string`() { + assertNull(helper.getStudioMediaId("")) + } + + @Test + fun `getStudioMediaId returns null for non-Studio external URL`() { + val url = "https://example.com/some-external-tool?param=value" + assertNull(helper.getStudioMediaId(url)) + } + + @Test + fun `getStudioMediaId handles UUID-style media ids`() { + val url = "https://example.com/lti/launch?custom_arc_media_id=e4be8b75-1234-5678-9abc-def012345678&custom_arc_start_at=0" + assertEquals("e4be8b75-1234-5678-9abc-def012345678", helper.getStudioMediaId(url)) + } + + // endregion + + // region isStudioVideoAvailableOffline + + @Test + fun `isStudioVideoAvailableOffline returns true when video file exists`() { + val mediaId = "test-media-id" + createVideoFile(mediaId) + assertTrue(helper.isStudioVideoAvailableOffline(mediaId)) + } + + @Test + fun `isStudioVideoAvailableOffline returns false when video file does not exist`() { + assertFalse(helper.isStudioVideoAvailableOffline("nonexistent-id")) + } + + @Test + fun `isStudioVideoAvailableOffline returns false when directory exists but file does not`() { + val mediaId = "dir-only" + File(tempFolder.root, "42/studio/$mediaId").mkdirs() + assertFalse(helper.isStudioVideoAvailableOffline(mediaId)) + } + + // endregion + + // region getStudioVideoUri + + @Test + fun `getStudioVideoUri returns correct file URI`() { + val mediaId = "video-id" + createVideoFile(mediaId) + val expected = "file://${tempFolder.root.absolutePath}/42/studio/$mediaId/$mediaId.mp4" + assertEquals(expected, helper.getStudioVideoUri(mediaId)) + } + + // endregion + + // region getStudioPosterUri + + @Test + fun `getStudioPosterUri returns file URI when poster exists`() { + val mediaId = "poster-id" + createPosterFile(mediaId) + val expected = "file://${tempFolder.root.absolutePath}/42/studio/$mediaId/poster.jpg" + assertEquals(expected, helper.getStudioPosterUri(mediaId)) + } + + @Test + fun `getStudioPosterUri returns null when poster does not exist`() { + val mediaId = "no-poster-id" + createVideoFile(mediaId) + assertNull(helper.getStudioPosterUri(mediaId)) + } + + @Test + fun `getStudioPosterUri returns null when directory does not exist`() { + assertNull(helper.getStudioPosterUri("nonexistent")) + } + + // endregion + + // region user id handling + + @Test + fun `uses correct user directory from ApiPrefs`() { + every { apiPrefs.user } returns User(id = 99) + val newHelper = StudioOfflineVideoHelper(context, apiPrefs) + val mediaId = "user-test" + + val dir = File(tempFolder.root, "99/studio/$mediaId") + dir.mkdirs() + File(dir, "$mediaId.mp4").createNewFile() + + assertTrue(newHelper.isStudioVideoAvailableOffline(mediaId)) + } + + // endregion + + private fun createVideoFile(mediaId: String) { + val dir = File(tempFolder.root, "42/studio/$mediaId") + dir.mkdirs() + File(dir, "$mediaId.mp4").createNewFile() + } + + private fun createPosterFile(mediaId: String) { + val dir = File(tempFolder.root, "42/studio/$mediaId") + dir.mkdirs() + File(dir, "poster.jpg").createNewFile() + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/AggregateProgressObserverTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/AggregateProgressObserverTest.kt index f021eb8699..e3d15687cb 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/AggregateProgressObserverTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/AggregateProgressObserverTest.kt @@ -20,7 +20,6 @@ package com.instructure.pandautils.features.offline.sync.progress import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.MutableLiveData import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.utils.NumberHelper import com.instructure.pandautils.features.offline.sync.AggregateProgressObserver @@ -41,6 +40,7 @@ import io.mockk.unmockkAll import junit.framework.TestCase.assertEquals import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -73,6 +73,10 @@ class AggregateProgressObserverTest { "${captor.captured} bytes" } + every { courseSyncProgressDao.findAllFlow() } returns MutableStateFlow(emptyList()) + every { fileSyncProgressDao.findAllFlow() } returns MutableStateFlow(emptyList()) + every { studioMediaProgressDao.findAllFlow() } returns MutableStateFlow(emptyList()) + Dispatchers.setMain(testDispatcher) } @@ -91,9 +95,9 @@ class AggregateProgressObserverTest { progressState = ProgressState.IN_PROGRESS ) - val courseProgressLiveData = MutableLiveData(listOf(courseProgress)) + val courseProgressFlow = MutableStateFlow(listOf(courseProgress)) - every { courseSyncProgressDao.findAllLiveData() } returns courseProgressLiveData + every { courseSyncProgressDao.findAllFlow() } returns courseProgressFlow aggregateProgressObserver = createObserver() @@ -109,7 +113,7 @@ class AggregateProgressObserverTest { progressState = ProgressState.COMPLETED ) - courseProgressLiveData.postValue(listOf(courseProgress)) + courseProgressFlow.value = listOf(courseProgress) assertEquals(100, aggregateProgressObserver.progressData.value?.progress) assertEquals(ProgressState.COMPLETED, aggregateProgressObserver.progressData.value?.progressState) @@ -143,11 +147,11 @@ class AggregateProgressObserverTest { fileSize = 2000, progressState = ProgressState.IN_PROGRESS, fileId = 2L ) - val courseLiveData = MutableLiveData(listOf(course1, course2)) - val fileLiveData = MutableLiveData(listOf(file1, file2)) + val courseFlow = MutableStateFlow(listOf(course1, course2)) + val fileFlow = MutableStateFlow(listOf(file1, file2)) - every { courseSyncProgressDao.findAllLiveData() } returns courseLiveData - every { fileSyncProgressDao.findAllLiveData() } returns fileLiveData + every { courseSyncProgressDao.findAllFlow() } returns courseFlow + every { fileSyncProgressDao.findAllFlow() } returns fileFlow aggregateProgressObserver = createObserver() @@ -167,8 +171,8 @@ class AggregateProgressObserverTest { progressState = ProgressState.COMPLETED ) - courseLiveData.postValue(listOf(course1, course2)) - fileLiveData.postValue(listOf(file1, file2)) + courseFlow.value = listOf(course1, course2) + fileFlow.value = listOf(file1, file2) assertEquals(49, aggregateProgressObserver.progressData.value?.progress) @@ -182,8 +186,8 @@ class AggregateProgressObserverTest { progressState = ProgressState.COMPLETED ) - courseLiveData.postValue(listOf(course1, course2)) - fileLiveData.postValue(listOf(file1, file2)) + courseFlow.value = listOf(course1, course2) + fileFlow.value = listOf(file1, file2) assertEquals(100, aggregateProgressObserver.progressData.value?.progress) assertEquals(ProgressState.COMPLETED, aggregateProgressObserver.progressData.value?.progressState) @@ -199,7 +203,7 @@ class AggregateProgressObserverTest { progressState = ProgressState.IN_PROGRESS ) - val courseLiveData = MutableLiveData(listOf(course1Progress)) + val courseFlow = MutableStateFlow(listOf(course1Progress)) var file1Progress = FileSyncProgressEntity( @@ -229,10 +233,10 @@ class AggregateProgressObserverTest { fileId = 3L ) - val fileLiveData = MutableLiveData(listOf(file1Progress, file2Progress, file3Progress)) + val fileFlow = MutableStateFlow(listOf(file1Progress, file2Progress, file3Progress)) - every { courseSyncProgressDao.findAllLiveData() } returns courseLiveData - every { fileSyncProgressDao.findAllLiveData() } returns fileLiveData + every { courseSyncProgressDao.findAllFlow() } returns courseFlow + every { fileSyncProgressDao.findAllFlow() } returns fileFlow aggregateProgressObserver = createObserver() @@ -250,8 +254,8 @@ class AggregateProgressObserverTest { tabs = CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.COMPLETED) }, ) - courseLiveData.postValue(listOf(course1Progress)) - fileLiveData.postValue(listOf(file1Progress, file2Progress, file3Progress)) + courseFlow.value = listOf(course1Progress) + fileFlow.value = listOf(file1Progress, file2Progress, file3Progress) // Course tabs and files are completed, but additional files are still in progress assertEquals(99, aggregateProgressObserver.progressData.value?.progress) @@ -268,7 +272,7 @@ class AggregateProgressObserverTest { fileId = 3L ) - fileLiveData.postValue(listOf(file1Progress, file2Progress, file3Progress)) + fileFlow.value = listOf(file1Progress, file2Progress, file3Progress) // Total size is updated with the external file assertEquals("${1000000 + 1000 + 2000 + 3000} bytes", aggregateProgressObserver.progressData.value?.totalSize) @@ -286,10 +290,8 @@ class AggregateProgressObserverTest { course1Progress = course1Progress.copy( progressState = ProgressState.COMPLETED ) - courseLiveData.postValue(listOf(course1Progress)) - fileLiveData.postValue( - listOf(file1Progress, file2Progress, file3Progress) - ) + courseFlow.value = listOf(course1Progress) + fileFlow.value = listOf(file1Progress, file2Progress, file3Progress) // External files are downloaded, progress should be 100% assertEquals( @@ -308,7 +310,7 @@ class AggregateProgressObserverTest { progressState = ProgressState.IN_PROGRESS ) - val courseLiveData = MutableLiveData(listOf(course1Progress)) + val courseFlow = MutableStateFlow(listOf(course1Progress)) var file1Progress = FileSyncProgressEntity( @@ -322,12 +324,12 @@ class AggregateProgressObserverTest { var studioMediaProgress = StudioMediaProgressEntity("1234", 0, 2000, ProgressState.IN_PROGRESS, 1L) - val fileLiveData = MutableLiveData(listOf(file1Progress)) - val studioLiveData = MutableLiveData(listOf(studioMediaProgress)) + val fileFlow = MutableStateFlow(listOf(file1Progress)) + val studioFlow = MutableStateFlow(listOf(studioMediaProgress)) - every { courseSyncProgressDao.findAllLiveData() } returns courseLiveData - every { fileSyncProgressDao.findAllLiveData() } returns fileLiveData - every { studioMediaProgressDao.findAllLiveData() } returns studioLiveData + every { courseSyncProgressDao.findAllFlow() } returns courseFlow + every { fileSyncProgressDao.findAllFlow() } returns fileFlow + every { studioMediaProgressDao.findAllFlow() } returns studioFlow aggregateProgressObserver = createObserver() @@ -346,14 +348,14 @@ class AggregateProgressObserverTest { progressState = ProgressState.COMPLETED ) - courseLiveData.postValue(listOf(course1Progress)) - fileLiveData.postValue(listOf(file1Progress)) + courseFlow.value = listOf(course1Progress) + fileFlow.value = listOf(file1Progress) // Course tabs and files are completed, but studio media is still in progress assertEquals(99, aggregateProgressObserver.progressData.value?.progress) studioMediaProgress = studioMediaProgress.copy(progress = 100, progressState = ProgressState.COMPLETED) - studioLiveData.postValue(listOf(studioMediaProgress)) + studioFlow.value = listOf(studioMediaProgress) // External files are downloaded, progress should be 100% assertEquals( @@ -371,9 +373,9 @@ class AggregateProgressObserverTest { progressState = ProgressState.IN_PROGRESS ) - val courseLiveData = MutableLiveData(listOf(course1)) + val courseFlow = MutableStateFlow(listOf(course1)) - every { courseSyncProgressDao.findAllLiveData() } returns courseLiveData + every { courseSyncProgressDao.findAllFlow() } returns courseFlow aggregateProgressObserver = createObserver() @@ -382,7 +384,7 @@ class AggregateProgressObserverTest { progressState = ProgressState.ERROR ) - courseLiveData.postValue(listOf(course1)) + courseFlow.value = listOf(course1) assertEquals(ProgressState.ERROR, aggregateProgressObserver.progressData.value?.progressState) } diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt index c46eaa65da..c463d12288 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt @@ -283,7 +283,7 @@ class PlannerItemExtensionsTest { contextName = null ) - every { context.getString(R.string.userCalendarToDo) } returns "User To-Do" + every { context.getString(R.string.userCalendarToDoNew) } returns "User To-Do" val result = plannerItem.getContextNameForPlannerItem(context, emptyList()) @@ -299,7 +299,7 @@ class PlannerItemExtensionsTest { contextName = "Computer Science" ) - every { context.getString(R.string.courseToDo, "CS101") } returns "CS101 To-Do" + every { context.getString(R.string.courseToDoNew, "CS101") } returns "CS101 To-Do" val result = plannerItem.getContextNameForPlannerItem(context, listOf(course)) diff --git a/translations/import.rb b/translations/import.rb index ba665b3a45..f4e95dea4b 100755 --- a/translations/import.rb +++ b/translations/import.rb @@ -4,8 +4,6 @@ projects_json = File.join('translations', 'projects.json') -hub_config = File.join(Dir.home, '.config', 'hub') - s3_source = 's3://instructure-translations/translations/android-canvas/' # Projects json file is required @@ -13,12 +11,6 @@ raise 'Missing projects.json; please run again from repository root' end -# Hub CLI and valid config are required for creating Pull Requests -raise 'Missing Hub CLI' if find_executable('hub').nil? -unless File.exist?(hub_config) || !ENV['GITHUB_TOKEN'].nil? - raise 'Must specify GITHUB_TOKEN or place Hub config at ~/.config/hub' -end - # AWS CLI and credentials are required for accessing the S3 bucket raise 'Missing AWS CLI' if find_executable('aws').nil? raise 'Missing AWS access key ID' if ENV['AWS_ACCESS_KEY_ID'].nil? @@ -60,7 +52,7 @@ next unless File.directory? src_dir Dir.glob("#{src_dir}/*.{xml,arb}") do |file| language = File.basename(src_dir).gsub('_', '-') - + # Fix Chinese directories if file.end_with?('.xml') language = language.gsub('zh-', 'b+zh+') @@ -127,9 +119,4 @@ success = system('git push origin HEAD') raise 'Failed to push new git branch' unless success -# Create pull request -success = system('hub pull-request -b master -m "Update translations"') -raise 'Failed to create pull request' unless success - puts 'Translations successfully imported!' -puts 'PLEASE REVIEW THE PULL REQUEST'