diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml new file mode 100644 index 0000000000..7fad64b118 --- /dev/null +++ b/.github/workflows/build-app.yml @@ -0,0 +1,189 @@ +name: Build App + +on: + workflow_call: + inputs: + app-type: + description: 'App type (Parent, Student, or Teacher)' + required: true + type: string + app-type-lower: + description: 'App type in lowercase (parent, student, or teacher)' + required: true + type: string + firebase-app-id-secret: + description: 'Name of the Firebase App ID secret' + required: true + type: string + secrets: + ACCESS_TOKEN: + required: true + ANDROID_RELEASE_KEYSTORE_B64: + required: true + FIREBASE_SERVICE_ACCOUNT_KEY: + required: true + FIREBASE_APP_ID: + required: true + +jobs: + build: + name: ${{ inputs.app-type-lower }}-build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 1 + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Cache Gradle Build Cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches/build-cache-* + .gradle + key: ${{ runner.os }}-gradle-build-cache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-gradle-build-cache- + + - name: Decode Release Keystore + run: | + echo "${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}" | base64 --decode > release.jks + chmod 600 release.jks + + - name: Setup Service account + env: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: Firebase service account key is not configured" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Build Release Notes + id: get_release_notes + run: | + echo "RELEASE_NOTES<> $GITHUB_OUTPUT + echo "${{ github.event.pull_request.title }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Install Firebase CLI + run: npm install -g firebase-tools + + - name: Setup Firebase App Id + run: | + if [ -z "${{ secrets.FIREBASE_APP_ID }}" ]; then + echo "Error: Firebase App ID is not configured" + exit 1 + fi + echo "${{ secrets.FIREBASE_APP_ID }}" > firebase_app_id.txt + + - name: Build debug and test APKs + run: | + ./gradle/gradlew -p apps :${{ inputs.app-type-lower }}:assembleQaDebug \ + :${{ inputs.app-type-lower }}:assembleQaDebugAndroidTest \ + :${{ inputs.app-type-lower }}:assembleDevDebugMinify \ + --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 + + - name: Upload QA debug APK + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.app-type-lower }}-qa-debug.apk + path: apps/${{ inputs.app-type-lower }}/build/outputs/apk/qa/debug/${{ inputs.app-type-lower }}-qa-debug.apk + + - name: Upload QA test APK + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.app-type-lower }}-qa-debug-androidTest.apk + path: apps/${{ inputs.app-type-lower }}/build/outputs/apk/androidTest/qa/debug/${{ inputs.app-type-lower }}-qa-debug-androidTest.apk + + - name: Upload Dev debug APK + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.app-type-lower }}-dev-debugMinify.apk + path: apps/${{ inputs.app-type-lower }}/build/outputs/apk/dev/debugMinify/${{ inputs.app-type-lower }}-dev-debugMinify.apk + + - name: Distribute app to Firebase App Distribution + env: + GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/service-account-key.json + run: | + firebase --version + FIREBASE_APP_ID="$(cat firebase_app_id.txt)" + + if ! firebase appdistribution:distribute "apps/${{ inputs.app-type-lower }}/build/outputs/apk/dev/debugMinify/${{ inputs.app-type-lower }}-dev-debugMinify.apk" \ + --app "$FIREBASE_APP_ID" \ + --release-notes "${{ steps.get_release_notes.outputs.RELEASE_NOTES }}" \ + --groups "Testers" > result.txt 2>&1; then + echo "Firebase distribution failed:" + cat result.txt + exit 1 + fi + cat result.txt + + - name: Prepare Comment Body + id: prepare_comment + run: | + INSTALL_URL=$(grep -o 'https://appdistribution\.firebase[^[:space:]]*' result.txt | head -1) + + if [ -z "$INSTALL_URL" ]; then + echo "Error: Could not extract install URL from Firebase output" + cat result.txt + exit 1 + fi + + INSTALL_URL_ESCAPED=$(printf '%s' "$INSTALL_URL" | sed 's/:/%3A/g; s/\//%2F/g; s/?/%3F/g; s/=/%3D/g; s/&/%26/g') + { + echo "body<" + echo "

${{ inputs.app-type }} Install Page

" + echo "EOF" + } >> $GITHUB_OUTPUT + + - name: Find Previous Comment + id: find_comment + uses: peter-evans/find-comment@v2 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + - name: Create or Update Comment + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find_comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: ${{ steps.prepare_comment.outputs.body }} + edit-mode: replace + + - name: Cleanup sensitive files + if: always() + run: | + rm -f release.jks + rm -f service-account-key.json + rm -f firebase_app_id.txt + rm -f result.txt diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 1f33912b41..13fa64b9d2 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -39,6 +39,7 @@ jobs: prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} + EVENT ACTION: ${{ github.event.action }} Please review this pull request and provide inline feedback using the GitHub review system. @@ -52,11 +53,39 @@ jobs: Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. Instructions: + ${{ github.event.action == 'synchronize' && ' + ## SYNCHRONIZE EVENT - UPDATE EXISTING REVIEW + This is an update to an existing PR. You must: + 1. Use the GitHub MCP tools to fetch your previous reviews on this PR + 2. Fetch the latest PR diff and identify what has changed since your last review + 3. Find your previous review summary comment (the one that starts with "## PR Review Summary" or similar) + 4. Post a NEW PR comment (not a review) with an update status that includes: + - Reference to your previous review + - Progress update using checkboxes: + - [x] Previously identified issues that have been resolved + - [ ] Previously identified issues still present + - [ ] New issues found in this update + - Brief summary of what changed + - Any new concerns or positive feedback + 5. For inline review comments: + - Resolve threads where the issue has been fixed + - Update existing review comment threads if partially addressed + - Add new inline review comments ONLY for new issues that require changes + - Do NOT add inline comments for positive feedback + 6. DO NOT create a new review summary - only post a progress update comment + + Use mcp__github__create_or_update_issue_comment to post the update.' || ' + ## NEW REVIEW EVENT + This is a new PR or initial review. You must: 1. Use the GitHub MCP tools to fetch the PR diff - 2. Add inline comments using the appropriate MCP tools for each specific piece of feedback on particular lines - 3. Submit the review with event type 'COMMENT' (not 'REQUEST_CHANGES') to publish as non-blocking feedback + 2. Create a review summary with checkboxes for any issues found: + - [ ] Issue description and location + 3. Add inline comments ONLY for specific code that needs changes + 4. DO NOT add inline comments for positive feedback - include positive feedback in the summary section only + 5. Submit the review with event type COMMENT (not REQUEST_CHANGES) to publish as non-blocking feedback + ' }} # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options - claude_args: '--allowedTools "mcp__github__create_pending_pull_request_review,mcp__github__add_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff"' + claude_args: '--allowedTools "mcp__github__create_pending_pull_request_review,mcp__github__add_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff,mcp__github__list_reviews,mcp__github__get_review,mcp__github__list_review_comments,mcp__github__update_review_comment,mcp__github__create_or_update_pull_request_review_comment,mcp__github__create_or_update_issue_comment,mcp__github__list_issue_comments,mcp__github__resolve_review_thread"' diff --git a/.github/workflows/pr-pipeline.yml b/.github/workflows/pr-pipeline.yml new file mode 100644 index 0000000000..15e1b9aa5c --- /dev/null +++ b/.github/workflows/pr-pipeline.yml @@ -0,0 +1,1488 @@ +name: Pull Request + +on: + pull_request: + types: [opened, synchronize, labeled] + branches-ignore: + - 'release/**' + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + parent-build: + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + uses: ./.github/workflows/build-app.yml + with: + app-type: Parent + app-type-lower: parent + firebase-app-id-secret: FIREBASE_ANDROID_PARENT_APP_ID + secrets: + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + 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 }} + + student-build: + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + uses: ./.github/workflows/build-app.yml + with: + app-type: Student + app-type-lower: student + firebase-app-id-secret: FIREBASE_ANDROID_STUDENT_APP_ID + secrets: + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + 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 }} + + teacher-build: + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + uses: ./.github/workflows/build-app.yml + with: + app-type: Teacher + app-type-lower: teacher + firebase-app-id-secret: FIREBASE_ANDROID_TEACHER_APP_ID + secrets: + ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} + 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 }} + + submodule-build-and-test: + name: submodule-build-and-test + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') || + contains(github.event.pull_request.body, 'Student') || + contains(github.event.pull_request.body, 'Teacher') + ) + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # Building Artifacts + - name: Build test and app APKs + run: | + ./gradle/gradlew -p apps :pandautils:assembleDebugAndroidTest + mv ./libs/pandautils/build/outputs/apk/androidTest/debug/pandautils-debug-androidTest.apk ./libs/pandautils/pandautils-test.apk + ./gradle/gradlew -p apps :pandautils:assembleDebugAndroidTest -DtestApplicationId=com.instructure.pandautils + mv ./libs/pandautils/build/outputs/apk/androidTest/debug/pandautils-debug-androidTest.apk ./libs/pandautils/pandautils-app.apk + + - name: Run submodule unit tests + run: | + ./gradle/gradlew -p apps testDebugUnitTest -x :dataseedingapi:test -x :teacher:test -x :student:test + + - name: Upload submodule test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: submodule-test-results + path: | + libs/*/build/reports/tests/testDebugUnitTest/ + libs/*/build/test-results/testDebugUnitTest/ + retention-days: 1 + + # Uploading Artifacts to GitHub + - name: Upload test APK + uses: actions/upload-artifact@v4 + with: + name: pandautils-test.apk + path: libs/pandautils/pandautils-test.apk + + - name: Upload app APK + uses: actions/upload-artifact@v4 + with: + name: pandautils-app.apk + path: libs/pandautils/pandautils-app.apk + + parent-unit-tests: + name: parent-unit-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 1 + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Run unit tests + run: | + ./gradle/gradlew -p apps :parent:testDevDebugUnitTest \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process + + - name: Upload parent test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: parent-test-results + path: | + apps/parent/build/reports/tests/testDevDebugUnitTest/ + apps/parent/build/test-results/testDevDebugUnitTest/ + retention-days: 1 + + student-unit-tests: + name: student-unit-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 1 + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Run unit tests + run: | + ./gradle/gradlew -p apps :student:testDevDebugUnitTest \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process + + - name: Upload student test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: student-test-results + path: | + apps/student/build/reports/tests/testDevDebugUnitTest/ + apps/student/build/test-results/testDevDebugUnitTest/ + retention-days: 1 + + teacher-unit-tests: + name: teacher-unit-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 1 + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Run unit tests + run: | + ./gradle/gradlew -p apps :teacher:testDevDebugUnitTest \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process + + - name: Upload teacher test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: teacher-test-results + path: | + apps/teacher/build/reports/tests/testDevDebugUnitTest/ + apps/teacher/build/test-results/testDevDebugUnitTest/ + retention-days: 1 + + horizon-unit-tests: + name: horizon-unit-tests + runs-on: ubuntu-latest + timeout-minutes: 30 + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 1 + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Cache Gradle Build Cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches/build-cache-* + .gradle + key: ${{ runner.os }}-gradle-build-cache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-gradle-build-cache- + + - name: Run unit tests + run: | + ./gradle/gradlew -p apps :horizon:testDebugUnitTest \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process + + - name: Upload horizon test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: horizon-test-results + path: | + libs/horizon/build/reports/tests/testDebugUnitTest/ + libs/horizon/build/test-results/testDebugUnitTest/ + retention-days: 1 + + horizon-test-build: + name: horizon-test-build + runs-on: ubuntu-latest + timeout-minutes: 30 + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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: Checkout repository + uses: actions/checkout@v4 + with: + submodules: 'recursive' + fetch-depth: 1 + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Cache Gradle Build Cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches/build-cache-* + .gradle + key: ${{ runner.os }}-gradle-build-cache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-gradle-build-cache- + + - name: Build Horizon test APK + run: | + ./gradle/gradlew -p apps :horizon:assembleDebugAndroidTest \ + --build-cache \ + --parallel \ + --max-workers=4 \ + --no-daemon \ + -Dorg.gradle.jvmargs="-Xmx6g -XX:+HeapDumpOnOutOfMemoryError" \ + -Dkotlin.compiler.execution.strategy=in-process + + - name: Upload Horizon test APK + uses: actions/upload-artifact@v4 + with: + name: horizon-debug-androidTest.apk + path: libs/horizon/build/outputs/apk/androidTest/debug/horizon-debug-androidTest.apk + + horizon-ui-tests: + name: horizon-ui-tests + runs-on: ubuntu-latest + timeout-minutes: 60 + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + needs: [student-build, horizon-test-build] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./libs/horizon/flank.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "student-qa-debug.apk" ]; then + mkdir -p apps/student/build/outputs/apk/qa/debug + mv student-qa-debug.apk/student-qa-debug.apk apps/student/build/outputs/apk/qa/debug/ + rm -rf student-qa-debug.apk + fi + if [ -d "horizon-debug-androidTest.apk" ]; then + mkdir -p libs/horizon/build/outputs/apk/androidTest/debug + mv horizon-debug-androidTest.apk/horizon-debug-androidTest.apk libs/horizon/build/outputs/apk/androidTest/debug/ + rm -rf horizon-debug-androidTest.apk + fi + + - name: Run Flank UI 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 to Splunk + run: ./apps/postProcessTestRun.bash horizon-ui results/`ls results` + 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.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 + + horizon-interaction-tests: + name: horizon-interaction-tests + runs-on: ubuntu-latest + timeout-minutes: 60 + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + needs: [student-build, horizon-test-build] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./libs/horizon/flank_interaction.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "student-qa-debug.apk" ]; then + mkdir -p apps/student/build/outputs/apk/qa/debug + mv student-qa-debug.apk/student-qa-debug.apk apps/student/build/outputs/apk/qa/debug/ + rm -rf student-qa-debug.apk + fi + if [ -d "horizon-debug-androidTest.apk" ]; then + mkdir -p libs/horizon/build/outputs/apk/androidTest/debug + mv horizon-debug-androidTest.apk/horizon-debug-androidTest.apk libs/horizon/build/outputs/apk/androidTest/debug/ + rm -rf horizon-debug-androidTest.apk + fi + + - name: Run Flank interaction 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 to Splunk + run: ./apps/postProcessTestRun.bash horizon-interaction results/`ls results` + 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.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 + + parent-portrait-ui-tests: + name: parent-portrait-ui-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + needs: [parent-build, parent-unit-tests] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/parent/flank.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "parent-qa-debug.apk" ]; then + mkdir -p apps/parent/build/outputs/apk/qa/debug + mv parent-qa-debug.apk/parent-qa-debug.apk apps/parent/build/outputs/apk/qa/debug/ + rm -rf parent-qa-debug.apk + fi + if [ -d "parent-qa-debug-androidTest.apk" ]; then + mkdir -p apps/parent/build/outputs/apk/androidTest/qa/debug + mv parent-qa-debug-androidTest.apk/parent-qa-debug-androidTest.apk apps/parent/build/outputs/apk/androidTest/qa/debug/ + rm -rf parent-qa-debug-androidTest.apk + fi + if [ -d "parent-dev-debugMinify.apk" ]; then + mkdir -p apps/parent/build/outputs/apk/dev/debugMinify + mv parent-dev-debugMinify.apk/parent-dev-debugMinify.apk apps/parent/build/outputs/apk/dev/debugMinify/ + rm -rf parent-dev-debugMinify.apk + fi + + - name: Run Flank UI 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 to Splunk + run: ./apps/postProcessTestRun.bash parent results/`ls results` + 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.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 + + parent-landscape-ui-tests: + name: parent-landscape-ui-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + needs: [parent-build, parent-unit-tests] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/parent/flank_landscape.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "parent-qa-debug.apk" ]; then + mkdir -p apps/parent/build/outputs/apk/qa/debug + mv parent-qa-debug.apk/parent-qa-debug.apk apps/parent/build/outputs/apk/qa/debug/ + rm -rf parent-qa-debug.apk + fi + if [ -d "parent-qa-debug-androidTest.apk" ]; then + mkdir -p apps/parent/build/outputs/apk/androidTest/qa/debug + mv parent-qa-debug-androidTest.apk/parent-qa-debug-androidTest.apk apps/parent/build/outputs/apk/androidTest/qa/debug/ + rm -rf parent-qa-debug-androidTest.apk + fi + if [ -d "parent-dev-debugMinify.apk" ]; then + mkdir -p apps/parent/build/outputs/apk/dev/debugMinify + mv parent-dev-debugMinify.apk/parent-dev-debugMinify.apk apps/parent/build/outputs/apk/dev/debugMinify/ + rm -rf parent-dev-debugMinify.apk + fi + + - name: Run Flank UI 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 to Splunk + run: ./apps/postProcessTestRun.bash parent results/`ls results` + 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.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 + + student-portrait-ui-tests: + name: student-portrait-ui-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + needs: [student-build, student-unit-tests] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/student/flank.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "student-qa-debug.apk" ]; then + mkdir -p apps/student/build/outputs/apk/qa/debug + mv student-qa-debug.apk/student-qa-debug.apk apps/student/build/outputs/apk/qa/debug/ + rm -rf student-qa-debug.apk + fi + if [ -d "student-qa-debug-androidTest.apk" ]; then + mkdir -p apps/student/build/outputs/apk/androidTest/qa/debug + mv student-qa-debug-androidTest.apk/student-qa-debug-androidTest.apk apps/student/build/outputs/apk/androidTest/qa/debug/ + rm -rf student-qa-debug-androidTest.apk + fi + if [ -d "student-dev-debugMinify.apk" ]; then + mkdir -p apps/student/build/outputs/apk/dev/debugMinify + mv student-dev-debugMinify.apk/student-dev-debugMinify.apk apps/student/build/outputs/apk/dev/debugMinify/ + rm -rf student-dev-debugMinify.apk + fi + + - name: Run Flank UI 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 to Splunk + run: ./apps/postProcessTestRun.bash student results/`ls results` + 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.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 + + student-landscape-ui-tests: + name: student-landscape-ui-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + needs: [student-build, student-unit-tests] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/student/flank_landscape.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "student-qa-debug.apk" ]; then + mkdir -p apps/student/build/outputs/apk/qa/debug + mv student-qa-debug.apk/student-qa-debug.apk apps/student/build/outputs/apk/qa/debug/ + rm -rf student-qa-debug.apk + fi + if [ -d "student-qa-debug-androidTest.apk" ]; then + mkdir -p apps/student/build/outputs/apk/androidTest/qa/debug + mv student-qa-debug-androidTest.apk/student-qa-debug-androidTest.apk apps/student/build/outputs/apk/androidTest/qa/debug/ + rm -rf student-qa-debug-androidTest.apk + fi + if [ -d "student-dev-debugMinify.apk" ]; then + mkdir -p apps/student/build/outputs/apk/dev/debugMinify + mv student-dev-debugMinify.apk/student-dev-debugMinify.apk apps/student/build/outputs/apk/dev/debugMinify/ + rm -rf student-dev-debugMinify.apk + fi + + - name: Run Flank UI 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 to Splunk + run: ./apps/postProcessTestRun.bash student results/`ls results` + 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.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 + + teacher-portrait-ui-tests: + name: teacher-portrait-ui-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + needs: [teacher-build, teacher-unit-tests] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/teacher/flank.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "teacher-qa-debug.apk" ]; then + mkdir -p apps/teacher/build/outputs/apk/qa/debug + mv teacher-qa-debug.apk/teacher-qa-debug.apk apps/teacher/build/outputs/apk/qa/debug/ + rm -rf teacher-qa-debug.apk + fi + if [ -d "teacher-qa-debug-androidTest.apk" ]; then + mkdir -p apps/teacher/build/outputs/apk/androidTest/qa/debug + mv teacher-qa-debug-androidTest.apk/teacher-qa-debug-androidTest.apk apps/teacher/build/outputs/apk/androidTest/qa/debug/ + rm -rf teacher-qa-debug-androidTest.apk + fi + if [ -d "teacher-dev-debugMinify.apk" ]; then + mkdir -p apps/teacher/build/outputs/apk/dev/debugMinify + mv teacher-dev-debugMinify.apk/teacher-dev-debugMinify.apk apps/teacher/build/outputs/apk/dev/debugMinify/ + rm -rf teacher-dev-debugMinify.apk + fi + + - name: Run Flank UI 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 to Splunk + run: ./apps/postProcessTestRun.bash teacher results/`ls results` + 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.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 + + teacher-landscape-ui-tests: + name: teacher-landscape-ui-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') + needs: [teacher-build, teacher-unit-tests] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/teacher/flank_landscape.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "teacher-qa-debug.apk" ]; then + mkdir -p apps/teacher/build/outputs/apk/qa/debug + mv teacher-qa-debug.apk/teacher-qa-debug.apk apps/teacher/build/outputs/apk/qa/debug/ + rm -rf teacher-qa-debug.apk + fi + if [ -d "teacher-qa-debug-androidTest.apk" ]; then + mkdir -p apps/teacher/build/outputs/apk/androidTest/qa/debug + mv teacher-qa-debug-androidTest.apk/teacher-qa-debug-androidTest.apk apps/teacher/build/outputs/apk/androidTest/qa/debug/ + rm -rf teacher-qa-debug-androidTest.apk + fi + if [ -d "teacher-dev-debugMinify.apk" ]; then + mkdir -p apps/teacher/build/outputs/apk/dev/debugMinify + mv teacher-dev-debugMinify.apk/teacher-dev-debugMinify.apk apps/teacher/build/outputs/apk/dev/debugMinify/ + rm -rf teacher-dev-debugMinify.apk + fi + + - name: Run Flank UI 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 to Splunk + run: ./apps/postProcessTestRun.bash teacher results/`ls results` + 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.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 + + parent-e2e-tests: + name: parent-e2e-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') && contains(github.event.pull_request.body, 'Run E2E test suite') + needs: [parent-build, parent-unit-tests] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/parent/flank_e2e.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "parent-qa-debug.apk" ]; then + mkdir -p apps/parent/build/outputs/apk/qa/debug + mv parent-qa-debug.apk/parent-qa-debug.apk apps/parent/build/outputs/apk/qa/debug/ + rm -rf parent-qa-debug.apk + fi + if [ -d "parent-qa-debug-androidTest.apk" ]; then + mkdir -p apps/parent/build/outputs/apk/androidTest/qa/debug + mv parent-qa-debug-androidTest.apk/parent-qa-debug-androidTest.apk apps/parent/build/outputs/apk/androidTest/qa/debug/ + rm -rf parent-qa-debug-androidTest.apk + fi + if [ -d "parent-dev-debugMinify.apk" ]; then + mkdir -p apps/parent/build/outputs/apk/dev/debugMinify + mv parent-dev-debugMinify.apk/parent-dev-debugMinify.apk apps/parent/build/outputs/apk/dev/debugMinify/ + rm -rf parent-dev-debugMinify.apk + fi + + - name: Run Flank E2E 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 to Splunk + run: ./apps/postProcessTestRun.bash parent results/`ls results` + 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.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 + + student-e2e-tests: + name: student-e2e-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') && contains(github.event.pull_request.body, 'Run E2E test suite') + needs: [student-build, student-unit-tests] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/student/flank_e2e.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "student-qa-debug.apk" ]; then + mkdir -p apps/student/build/outputs/apk/qa/debug + mv student-qa-debug.apk/student-qa-debug.apk apps/student/build/outputs/apk/qa/debug/ + rm -rf student-qa-debug.apk + fi + if [ -d "student-qa-debug-androidTest.apk" ]; then + mkdir -p apps/student/build/outputs/apk/androidTest/qa/debug + mv student-qa-debug-androidTest.apk/student-qa-debug-androidTest.apk apps/student/build/outputs/apk/androidTest/qa/debug/ + rm -rf student-qa-debug-androidTest.apk + fi + if [ -d "student-dev-debugMinify.apk" ]; then + mkdir -p apps/student/build/outputs/apk/dev/debugMinify + mv student-dev-debugMinify.apk/student-dev-debugMinify.apk apps/student/build/outputs/apk/dev/debugMinify/ + rm -rf student-dev-debugMinify.apk + fi + + - name: Run Flank E2E 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 to Splunk + run: ./apps/postProcessTestRun.bash student results/`ls results` + 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.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 + + teacher-e2e-tests: + name: teacher-e2e-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') && contains(github.event.pull_request.body, 'Run E2E test suite') + needs: [teacher-build, teacher-unit-tests] + 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: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: | + if [ -z "${FIREBASE_SERVICE_ACCOUNT_KEY}" ]; then + echo "Error: GCLOUD_KEY secret is not set" + exit 1 + fi + echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + chmod 600 service-account-key.json + + - name: Setup Flank config + run: cp ./apps/teacher/flank_e2e.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + if [ -d "teacher-qa-debug.apk" ]; then + mkdir -p apps/teacher/build/outputs/apk/qa/debug + mv teacher-qa-debug.apk/teacher-qa-debug.apk apps/teacher/build/outputs/apk/qa/debug/ + rm -rf teacher-qa-debug.apk + fi + if [ -d "teacher-qa-debug-androidTest.apk" ]; then + mkdir -p apps/teacher/build/outputs/apk/androidTest/qa/debug + mv teacher-qa-debug-androidTest.apk/teacher-qa-debug-androidTest.apk apps/teacher/build/outputs/apk/androidTest/qa/debug/ + rm -rf teacher-qa-debug-androidTest.apk + fi + if [ -d "teacher-dev-debugMinify.apk" ]; then + mkdir -p apps/teacher/build/outputs/apk/dev/debugMinify + mv teacher-dev-debugMinify.apk/teacher-dev-debugMinify.apk apps/teacher/build/outputs/apk/dev/debugMinify/ + rm -rf teacher-dev-debugMinify.apk + fi + + - name: Run Flank E2E 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 to Splunk + run: ./apps/postProcessTestRun.bash teacher results/`ls results` + 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.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 + + submodule-ui-tests: + name: submodule-ui-tests + runs-on: ubuntu-latest + if: >- + ( + (github.event.action == 'opened' || github.event.action == 'synchronize') || + (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') || + contains(github.event.pull_request.body, 'Student') || + contains(github.event.pull_request.body, 'Teacher') + ) + needs: submodule-build-and-test + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download test APK artifact + uses: actions/download-artifact@v4 + with: + name: pandautils-test.apk + path: . + + - name: Download app APK artifact + uses: actions/download-artifact@v4 + with: + name: pandautils-app.apk + path: . + + - name: Setup Service account + env: + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.GCLOUD_KEY }} + run: echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > service-account-key.json + + - name: Setup Flank config + run: cp ./libs/pandautils/flank.yml ./flank.yml + + - name: Copy APKs to expected locations + run: | + mkdir -p libs/pandautils + mv pandautils-test.apk libs/pandautils/ + mv pandautils-app.apk libs/pandautils/ + + - name: Run Flank E2E 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' + unit-test-report: + name: unit-test-report + runs-on: ubuntu-latest + if: always() + needs: [parent-unit-tests, student-unit-tests, teacher-unit-tests, horizon-unit-tests, submodule-build-and-test] + permissions: + pull-requests: write + steps: + - name: Download all test results + uses: actions/download-artifact@v4 + with: + pattern: '*-test-results' + path: test-results/ + + - name: Parse test results and generate report + id: parse + run: | + cat > parse_tests.py <<'PYTHON_SCRIPT' + import os + import re + from pathlib import Path + from html.parser import HTMLParser + import xml.etree.ElementTree as ET + + class TestReportParser(HTMLParser): + def __init__(self): + super().__init__() + self.in_value = False + self.current_id = None + self.data = {} + + def handle_starttag(self, tag, attrs): + attrs_dict = dict(attrs) + if tag == 'div' and 'infoBox' in attrs_dict.get('class', ''): + self.current_id = attrs_dict.get('id') + elif tag == 'div' and (attrs_dict.get('class') == 'counter' or attrs_dict.get('class') == 'percent'): + self.in_value = True + + def handle_endtag(self, tag): + if tag == 'div': + self.in_value = False + + def handle_data(self, data): + data = data.strip() + if self.in_value and self.current_id and data: + self.data[self.current_id] = data + self.in_value = False + + def parse_test_report(html_file): + """Parse Gradle HTML test report""" + if not Path(html_file).exists(): + return None + + with open(html_file, 'r') as f: + content = f.read() + + parser = TestReportParser() + parser.feed(content) + + # Safely parse integer values with error handling + try: + tests = int(parser.data.get('tests', '0')) + failures = int(parser.data.get('failures', '0')) + ignored = int(parser.data.get('ignored', '0')) + except (ValueError, TypeError): + # If parsing fails, return None to indicate corrupted report + return None + + return { + 'tests': tests, + 'failures': failures, + 'ignored': ignored, + 'duration': parser.data.get('duration', 'N/A'), + 'success_rate': parser.data.get('successRate', 'N/A') + } + + def extract_failed_tests(index_html_path): + """Extract failed test names from HTML class reports in the same directory""" + failed_tests = [] + # The classes directory is a sibling of the index.html file + report_dir = Path(index_html_path).parent + classes_dir = report_dir / 'classes' + + if not classes_dir.exists(): + return failed_tests + + for class_file in classes_dir.glob('*.html'): + try: + with open(class_file, 'r') as f: + content = f.read() + + # Find failed test names + matches = re.findall(r'

([^<]+)

', content) + if matches: + class_name = class_file.stem + for test_name in matches: + failed_tests.append(f"{class_name}.{test_name}") + except: + # Skip files that can't be read + pass + + return failed_tests + + modules = { + 'parent-test-results': '📱 Parent App', + 'student-test-results': '📱 Student App', + 'teacher-test-results': '📱 Teacher App', + 'horizon-test-results': '🌅 Horizon', + 'submodule-test-results': '📦 Submodules' + } + + print("## 🧪 Unit Test Results\n") + + total_tests = 0 + total_failures = 0 + total_ignored = 0 + all_success = True + + for module_dir, module_name in modules.items(): + report_files = [] + + # Search for all index.html files in the downloaded artifacts + for root, dirs, files in os.walk(f'test-results/{module_dir}'): + if 'index.html' in files: + report_files.append(os.path.join(root, 'index.html')) + + if not report_files: + # Job was skipped, don't show in output + continue + + # Aggregate results from all report files (important for submodules) + module_tests = 0 + module_failures = 0 + module_ignored = 0 + module_duration = 0.0 + failed_to_parse = 0 + + for report_file in report_files: + result = parse_test_report(report_file) + if not result: + failed_to_parse += 1 + continue + + module_tests += result['tests'] + module_failures += result['failures'] + module_ignored += result['ignored'] + + # Parse duration (remove 's' and convert to float) + try: + duration_str = result['duration'].replace('s', '') + module_duration += float(duration_str) + except: + pass + + if module_tests == 0 and failed_to_parse > 0: + # All reports failed to parse + print(f"### ⚠️ {module_name}") + print(f"- Failed to parse test results ({failed_to_parse} report(s))\n") + continue + + # Calculate success rate + if module_tests > 0: + success_rate = f"{((module_tests - module_failures) / module_tests * 100):.0f}%" + else: + success_rate = "N/A" + + total_tests += module_tests + total_failures += module_failures + total_ignored += module_ignored + + if module_failures > 0: + all_success = False + emoji = '❌' + else: + emoji = '✅' + + print(f"### {emoji} {module_name}") + print(f"- **Tests:** {module_tests} total, {module_failures} failed, {module_ignored} skipped") + print(f"- **Duration:** {module_duration:.3f}s") + print(f"- **Success Rate:** {success_rate}") + + # Show failed tests if there are any + if module_failures > 0: + # Extract failed test names from all report files + failed_tests = [] + for report_file in report_files: + failed_tests.extend(extract_failed_tests(report_file)) + + if failed_tests: + print(f"\n
") + print(f"❌ Failed Tests ({len(failed_tests)})\n") + for test in failed_tests: + print(f"- `{test}`") + print(f"
") + + print() + + print("---") + print(f"### 📊 Summary") + print(f"- **Total Tests:** {total_tests}") + print(f"- **Failed:** {total_failures}") + print(f"- **Skipped:** {total_ignored}") + if all_success and total_tests > 0: + print(f"- **Status:** ✅ All tests passed!") + elif total_tests > 0: + print(f"- **Status:** ❌ {total_failures} test(s) failed") + else: + print(f"- **Status:** ⚠️ No test results found") + + PYTHON_SCRIPT + + set -eo pipefail + python3 parse_tests.py > test-report.md + + - name: Post or update PR comment + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = fs.readFileSync('test-report.md', 'utf8'); + const marker = ''; + const body = marker + '\n' + report + '\n\n_Last updated: ' + new Date().toUTCString() + '_'; + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existingComment = comments.find(comment => + comment.body.includes(marker) + ); + + if (existingComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: body + }); + console.log('Updated existing comment'); + } else { + // Create new comment + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + console.log('Created new comment'); + } diff --git a/apps/.claude/skills/build-apps/SKILL.md b/apps/.claude/skills/build-apps/SKILL.md new file mode 100644 index 0000000000..fa864ff3f1 --- /dev/null +++ b/apps/.claude/skills/build-apps/SKILL.md @@ -0,0 +1,65 @@ +--- +name: build +description: Build Canvas Android apps (Student, Teacher, Parent) using Gradle. Use when user mentions building, compiling, assembling, or making the app. Provides commands for dev, qa, and prod build variants. +--- + +# Build Canvas Android Apps + +Build the Canvas Android apps (Student, Teacher, Parent) using Gradle. + +## Build Location + +All build commands must be run from the repository root (`canvas-android/`), not the `apps/` directory. + +## Build Commands + +### Build Individual Apps + +Build a specific app in the dev debug variant: + +```bash +# Build Student app +./gradle/gradlew -p apps :student:assembleDevDebug + +# Build Teacher app +./gradle/gradlew -p apps :teacher:assembleDevDebug + +# Build Parent app +./gradle/gradlew -p apps :parent:assembleDevDebug +``` + +### Build All Apps + +Build all three apps at once: + +```bash +./gradle/gradlew -p apps assembleAllApps +``` + +### Clean Build + +Remove build artifacts before building: + +```bash +./gradle/gradlew -p apps clean +``` + +## Build Variants + +- **Flavors**: `dev`, `qa`, `prod` +- **Types**: `debug`, `debugMinify`, `release` +- **Common variants**: + - `devDebug` - for development + - `qaDebug` - for testing + +## Examples + +Build Teacher app for QA testing: +```bash +./gradle/gradlew -p apps :teacher:assembleQaDebug +``` + +Clean and rebuild Student app: +```bash +./gradle/gradlew -p apps clean :student:assembleDevDebug +``` \ No newline at end of file diff --git a/apps/.claude/skills/deploy/SKILL.md b/apps/.claude/skills/deploy/SKILL.md new file mode 100644 index 0000000000..a6e0c05312 --- /dev/null +++ b/apps/.claude/skills/deploy/SKILL.md @@ -0,0 +1,124 @@ +--- +name: deploy +description: Install and deploy Canvas Android apps to connected devices or emulators using adb and Gradle. Use when user mentions installing, deploying, running on device, launching app, or working with emulators. +allowed-tools: Bash +--- + +# Deploy Canvas Android Apps + +Install and deploy Canvas Android apps to connected devices or emulators. + +## Deploy Location + +All deployment commands must be run from the repository root (`canvas-android/`), not the `apps/` directory. + +## Check Connected Devices + +Before deploying, always check for connected devices or emulators: + +```bash +adb devices -l +``` + +If no devices are connected, start an emulator first. + +## Install to Device + +Install an app to a connected device or emulator: + +```bash +# Install Student app +./gradle/gradlew -p apps :student:installDevDebug + +# Install Teacher app +./gradle/gradlew -p apps :teacher:installDevDebug + +# Install Parent app +./gradle/gradlew -p apps :parent:installDevDebug +``` + +## Launch App + +After installation, launch the app using monkey: + +```bash +# Launch Student app +adb shell monkey -p com.instructure.candroid -c android.intent.category.LAUNCHER 1 + +# Launch Teacher app +adb shell monkey -p com.instructure.teacher -c android.intent.category.LAUNCHER 1 + +# Launch Parent app +adb shell monkey -p com.instructure.parentapp -c android.intent.category.LAUNCHER 1 +``` + +## Package Names + +- **Student**: `com.instructure.candroid` +- **Teacher**: `com.instructure.teacher` +- **Parent**: `com.instructure.parentapp` + +## Target Specific Device + +If multiple devices are connected, target a specific device: + +```bash +# Install to specific device using Gradle +./gradle/gradlew -p apps :student:installDevDebug -Pandroid.injected.device.serial=emulator-5554 + +# Or use adb with -s flag +adb -s emulator-5554 shell monkey -p com.instructure.candroid -c android.intent.category.LAUNCHER 1 +``` + +## Common ADB Commands + +### View Logs + +```bash +# View logs for Student app +adb logcat | grep "candroid" + +# View logs for Teacher app +adb logcat | grep "teacher" +``` + +### Clear App Data + +```bash +# Clear Student app data +adb shell pm clear com.instructure.candroid + +# Clear Teacher app data +adb shell pm clear com.instructure.teacher + +# Clear Parent app data +adb shell pm clear com.instructure.parentapp +``` + +### Uninstall Apps + +```bash +# Uninstall Student app +adb uninstall com.instructure.candroid + +# Uninstall Teacher app +adb uninstall com.instructure.teacher + +# Uninstall Parent app +adb uninstall com.instructure.parentapp +``` + +## Examples + +Install and launch Student app: +```bash +./gradle/gradlew -p apps :student:installDevDebug +adb shell monkey -p com.instructure.candroid -c android.intent.category.LAUNCHER 1 +``` + +Reinstall Teacher app (clear data first): +```bash +adb shell pm clear com.instructure.teacher +./gradle/gradlew -p apps :teacher:installDevDebug +adb shell monkey -p com.instructure.teacher -c android.intent.category.LAUNCHER 1 +``` \ No newline at end of file diff --git a/apps/.claude/skills/pr/SKILL.md b/apps/.claude/skills/pr/SKILL.md new file mode 100644 index 0000000000..cd1388b953 --- /dev/null +++ b/apps/.claude/skills/pr/SKILL.md @@ -0,0 +1,165 @@ +--- +name: pr +description: Create pull requests for Canvas Android following project conventions. Use when user mentions creating PR, pull request, opening PR, or submitting changes for review. Includes PR template requirements and affects field guidelines. +allowed-tools: Bash, Read +--- + +# Create Pull Requests for Canvas Android + +Create pull requests for Canvas Android following project conventions and using the standard template. + +## PR Template Location + +The PR template is located at `/PULL_REQUEST_TEMPLATE` in the repository root. + +## Creating a PR + +Use the GitHub CLI to create pull requests: + +```bash +# Create PR (automatically uses template) +gh pr create + +# Or with title and body +gh pr create --title "Your PR Title" --body "$(cat /path/to/description.md)" +``` + +The template will be automatically loaded when using `gh pr create`. + +### PR Title Format + +**CRITICAL**: The PR title MUST include the affected app(s) in square brackets AFTER the ticket ID. + +Format: `[TICKET-ID][AppName] Description` + +Examples: +- `[MBL-19497][Student] Fix bookmark URL placeholders from notifications` +- `[MBL-12345][Teacher] Add assignment grading improvements` +- `[MBL-67890][Student][Teacher] Fix discussion loading for both apps` +- `[MBL-11111][All] Update login flow across all apps` + +**Rules:** +- The ticket ID comes FIRST, then the app name(s) +- Always include the app name(s) based on the `affects:` field in the PR body +- If `affects: Student`, use `[Student]` +- If `affects: Student, Teacher`, use `[Student][Teacher]` +- If `affects: Student, Teacher, Parent`, use `[All]` +- The app tags come AFTER the ticket ID and BEFORE the description + +## Template Requirements + +The PR template includes the following sections that must be completed: + +### 1. Test Plan Description + +Describe how to test the changes. Include: +- Steps to reproduce/verify the fix or feature +- Expected behavior +- Any prerequisites or setup needed + +### 2. Issue References + +Use `refs:` followed by issue numbers: + +```markdown +refs: MBL-12345 +``` + +or for multiple issues: + +```markdown +refs: MBL-12345, MBL-67890 +``` + +### 3. Impact Scope (affects field) + +**IMPORTANT**: Use `affects:` to specify which apps are impacted by the changes. + +Valid values: +- `Student` +- `Teacher` +- `Parent` + +Examples: + +```markdown +affects: Student +``` + +```markdown +affects: Student, Teacher +``` + +```markdown +affects: Student, Teacher, Parent +``` + +### 4. Release Note + +Provide a user-facing description of changes. This should be: +- Written for end users, not developers +- Clear and concise +- Focused on user impact + +### 5. Checklist + +Complete the following items before marking PR as ready: + +- [ ] Dark/light mode testing +- [ ] Landscape/tablet testing +- [ ] Accessibility testing +- [ ] Product approval (if needed) + +## Important Notes + +- **DO NOT** include E2E tests or screenshots sections unless specifically needed +- Always include the `affects:` field to specify which apps are impacted +- Reference the related issue(s) with `refs:` +- Complete the checklist before marking the PR as ready for review + +## Examples + +### Example PR for Student App Only + +**Title:** `[MBL-19453][Student] Add dashboard widget customization` + +```markdown +Test plan: +1. Navigate to Dashboard +2. Verify widgets are displayed correctly +3. Test widget reordering + +refs: MBL-19453 +affects: Student +release note: Students can now customize their dashboard with widgets +``` + +### Example PR for Multiple Apps + +**Title:** `[MBL-12345][Student][Teacher] Improve discussion loading performance` + +```markdown +Test plan: +1. Open any course +2. Verify discussion loading +3. Test comment threading + +refs: MBL-12345 +affects: Student, Teacher +release note: Improved discussion loading performance +``` + +### Example PR for All Apps + +**Title:** `[MBL-67890][All] Update login flow` + +```markdown +Test plan: +1. Launch any app +2. Log in with credentials +3. Verify successful authentication + +refs: MBL-67890 +affects: Student, Teacher, Parent +release note: Updated login experience for improved security +``` \ No newline at end of file diff --git a/apps/.claude/skills/test/SKILL.md b/apps/.claude/skills/test/SKILL.md new file mode 100644 index 0000000000..cc28961842 --- /dev/null +++ b/apps/.claude/skills/test/SKILL.md @@ -0,0 +1,132 @@ +--- +name: test +description: Run unit tests and instrumentation tests for Canvas Android apps. Use when user mentions testing, running tests, JUnit, Espresso, or checking test results. Includes commands for Student, Teacher, and Parent apps. +allowed-tools: Bash, Read +--- + +# Test Canvas Android Apps + +Run unit tests, instrumentation tests, and Espresso tests for Canvas Android apps. + +## Test Location + +All test commands must be run from the repository root (`canvas-android/`), not the `apps/` directory. + +## Unit Tests + +Unit tests verify business logic in isolation using Mockk for mocking. + +### Prerequisites + +- Set Build Variant to `qaDebug` in Android Studio, or +- Use command line with `testQaDebugUnitTest` as shown below + +### Run Unit Tests for Apps + +```bash +# Student app - all unit tests +./gradle/gradlew -p apps :student:testQaDebugUnitTest + +# Teacher app - all unit tests +./gradle/gradlew -p apps :teacher:testQaDebugUnitTest + +# Parent app - all unit tests +./gradle/gradlew -p apps :parent:testQaDebugUnitTest +``` + +### Run Unit Tests for Shared Libraries + +```bash +# Test a specific module (e.g., pandautils) +./gradle/gradlew -p apps :pandautils:testDebugUnitTest + +# Test specific class or package +./gradle/gradlew -p apps :pandautils:testDebugUnitTest --tests "com.instructure.pandautils.features.discussion.router.*" + +# Force re-run tests (ignore cache) +./gradle/gradlew -p apps :pandautils:testDebugUnitTest --rerun-tasks +``` + +### Run Specific Unit Tests + +Use the `--tests` flag to run specific test classes or methods: + +```bash +# Run single test class +./gradle/gradlew -p apps :student:testQaDebugUnitTest --tests "com.instructure.student.features.dashboard.DashboardViewModelTest" + +# Run tests matching a pattern +./gradle/gradlew -p apps :student:testQaDebugUnitTest --tests "com.instructure.student.features.dashboard.widget.*" +``` + +### Test Structure + +- **Unit tests**: `src/test/java/com/instructure/{app}/features/{feature}/` +- **Instrumentation tests**: `src/androidTest/java/com/instructure/{app}/ui/{feature}/` + +## Instrumentation/Espresso Tests + +Instrumentation tests run on a device or emulator to test UI interactions. + +### Prerequisites + +Before running UI tests, check for connected devices: + +```bash +adb devices -l +``` + +If multiple devices are connected: +- Prefer running on an emulator if available +- Use `ANDROID_SERIAL` environment variable or `-s` flag to specify a device + +### Run All Instrumentation Tests + +```bash +# Student app +./gradle/gradlew -p apps :student:connectedQaDebugAndroidTest + +# Teacher app +./gradle/gradlew -p apps :teacher:connectedQaDebugAndroidTest +``` + +### Run Specific Instrumentation Test + +```bash +# Run single test class +./gradle/gradlew -p apps :student:connectedQaDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.instructure.student.ui.dashboard.DashboardPageTest +``` + +### Target Specific Device + +If multiple devices are connected: + +```bash +# Use environment variable +ANDROID_SERIAL=emulator-5554 ./gradle/gradlew -p apps :student:connectedQaDebugAndroidTest + +# Or use adb flag +adb -s emulator-5554 shell ... +``` + +## Testing Patterns + +- Write unit tests in the same style as existing tests (check `student/src/test/`) +- Write instrumentation tests following existing patterns (check `student/src/androidTest/`) +- Mock dependencies with Mockk +- Use test doubles for repositories in ViewModel tests +- Espresso tests should use page object pattern from `:espresso` module +- Ensure tests are isolated and repeatable + +## Examples + +Run all widget tests: +```bash +./gradle/gradlew -p apps :student:testQaDebugUnitTest --tests "com.instructure.student.features.dashboard.widget.*" +``` + +Run tests and view report: +```bash +./gradle/gradlew -p apps :student:testQaDebugUnitTest +open apps/student/build/reports/tests/testQaDebugUnitTest/index.html +``` \ No newline at end of file diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index f7db45f50d..37d0fb4029 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -2,6 +2,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Skills + +Operational commands are available as skills in `.claude/skills/`: +- `/build` - Build commands for all apps +- `/test` - Unit and instrumentation test commands +- `/deploy` - Device deployment and ADB commands +- `/pr` - Pull request creation guidelines + +Use these skills when you need specific command references. + ## Project Overview Canvas Android is a multi-app learning management system project with three main applications (Student, Teacher, Parent) sharing common libraries. The apps are built with Kotlin, Jetpack Compose (modern UI) and XML layouts (legacy), following MVVM architecture with Dagger Hilt for dependency injection. @@ -25,66 +35,6 @@ Canvas Android is a multi-app learning management system project with three main - `espresso/` - UI testing framework built on Espresso - `dataseedingapi/` - gRPC wrapper for Canvas data seeding in tests -## Build Commands - -Run from repository root (`canvas-android/`), not the `apps/` directory: - -```bash -# Build Student app (dev debug variant) -./gradle/gradlew -p apps :student:assembleDevDebug - -# Build Teacher app (dev debug variant) -./gradle/gradlew -p apps :teacher:assembleDevDebug - -# Build Parent app (dev debug variant) -./gradle/gradlew -p apps :parent:assembleDevDebug - -# Build all apps -./gradle/gradlew -p apps assembleAllApps - -# Clean build -./gradle/gradlew -p apps clean -``` - -## Running Tests - -**Unit Tests:** -1. Set Build Variant to `qaDebug` in Android Studio -2. Run tests by clicking the play button next to test cases/classes -3. Or via command line: -```bash -./gradle/gradlew -p apps :student:testQaDebugUnitTest -./gradle/gradlew -p apps :teacher:testQaDebugUnitTest -./gradle/gradlew -p apps :parent:testQaDebugUnitTest -``` - -**Shared Library Tests:** -```bash -# Test a specific module -./gradle/gradlew -p apps :pandautils:testDebugUnitTest - -# Test specific class -./gradle/gradlew -p apps :pandautils:testDebugUnitTest --tests "com.instructure.pandautils.features.discussion.router.DiscussionRouterViewModelTest.*" - -# Force re-run tests (ignore cache) -./gradle/gradlew -p apps :pandautils:testDebugUnitTest --rerun-tasks -``` - -**Instrumentation/Espresso Tests:** -```bash -./gradle/gradlew -p apps :student:connectedQaDebugAndroidTest -./gradle/gradlew -p apps :teacher:connectedQaDebugAndroidTest -``` - -**Single Test:** -```bash -# Unit test -./gradle/gradlew -p apps :student:testQaDebugUnitTest --tests "com.instructure.student.SpecificTest" - -# Instrumentation test -./gradle/gradlew -p apps :student:connectedQaDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.instructure.student.ui.SpecificTest -``` - ## Architecture ### Feature Organization @@ -143,6 +93,7 @@ Dependencies are centralized in `buildSrc/src/main/java/GlobalDependencies.kt` w - Self-documenting code without inline comments unless specifically requested - Use descriptive variable and function names - **Companion objects**: Place companion objects at the bottom of the class, following Kotlin style guides. For simple private constants used only within a file, consider using top-level constants instead +- Always use imports instead of fully qualified names in code ### Component Patterns - Use existing utility functions and shared components from `pandautils` @@ -175,43 +126,6 @@ The project uses a Router pattern for navigation between features: - **Routing Methods**: Routers use `RouteMatcher.route()` to navigate to other fragments using `Route` objects - **Example Flow**: ViewModel → Action → Fragment.handleAction() → Router.routeTo*() → RouteMatcher.route() -## Device Deployment - -**Install to Connected Device:** -```bash -# Install Student app -./gradle/gradlew -p apps :student:installDevDebug - -# Install Teacher app -./gradle/gradlew -p apps :teacher:installDevDebug - -# Install Parent app -./gradle/gradlew -p apps :parent:installDevDebug -``` - -**Launch App:** -```bash -# Check connected devices -adb devices - -# Launch using monkey (works with any launcher activity) -adb shell monkey -p com.instructure.candroid -c android.intent.category.LAUNCHER 1 -adb shell monkey -p com.instructure.teacher -c android.intent.category.LAUNCHER 1 -adb shell monkey -p com.instructure.parentapp -c android.intent.category.LAUNCHER 1 -``` - -**Common ADB Commands:** -```bash -# View logs -adb logcat | grep "candroid" - -# Clear app data -adb shell pm clear com.instructure.candroid - -# Uninstall app -adb uninstall com.instructure.candroid -``` - ## Additional Context ### Initial Setup @@ -229,15 +143,4 @@ Each app has ProGuard rules in `{app}/proguard-rules.txt` The project uses `PrivateData.merge()` to inject private configuration (API keys, tokens) from `android-vault/private-data/`. These are not in version control. ### Localization -Apps support multiple languages. Translation tags are scanned at build time via `LocaleScanner.getAvailableLanguageTags()`. - -### Pull Requests -When creating a pull request, use the template located at `/PULL_REQUEST_TEMPLATE` in the repository root. The template includes: -- Test plan description -- Issue references (refs:) -- Impact scope (affects:) -- Release note -- Screenshots table (Before/After) -- Checklist (E2E tests, dark/light mode, landscape/tablet, accessibility, product approval) - -Use `gh pr create` with the template to create PRs from the command line. \ No newline at end of file +Apps support multiple languages. Translation tags are scanned at build time via `LocaleScanner.getAvailableLanguageTags()`. \ No newline at end of file diff --git a/apps/parent/build.gradle b/apps/parent/build.gradle index 1736c5549c..acfadaac45 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 61 - versionName "4.5.0" + versionCode 63 + versionName "4.7.0" buildConfigField "boolean", "IS_TESTING", "false" testInstrumentationRunner 'com.instructure.parentapp.ui.espresso.ParentHiltTestRunner' diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/GradesListE2ETest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CourseDetailsGradesE2ETest.kt similarity index 99% rename from apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/GradesListE2ETest.kt rename to apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CourseDetailsGradesE2ETest.kt index 043611177a..5494ba3d7b 100644 --- a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/GradesListE2ETest.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CourseDetailsGradesE2ETest.kt @@ -39,7 +39,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @HiltAndroidTest -class GradesListE2ETest : ParentComposeTest() { +class CourseDetailsGradesE2ETest : ParentComposeTest() { override fun displaysPageObjects() = Unit diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CustomStatusesE2ETest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CustomStatusesE2ETest.kt new file mode 100644 index 0000000000..037a036bfc --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/e2e/compose/CustomStatusesE2ETest.kt @@ -0,0 +1,106 @@ +package com.instructure.parentapp.ui.e2e.compose + +import android.util.Log +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.E2E +import com.instructure.dataseeding.api.AssignmentsApi +import com.instructure.dataseeding.api.CustomStatusApi +import com.instructure.dataseeding.api.SubmissionsApi +import com.instructure.dataseeding.model.GradingType +import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.util.CanvasNetworkAdapter.adminToken +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.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.After +import org.junit.Test + +@HiltAndroidTest +class CustomStatusesE2ETest: ParentComposeTest() { + + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + private var customStatusId: String? = null + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CUSTOM_STATUSES, TestCategory.E2E) + fun testCustomStatusesE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, courses = 1, teachers = 1, parents = 1) + val course = data.coursesList[0] + val parent = data.parentsList[0] + val student = data.studentsList[0] + val teacher = data.teachersList[0] + + Log.d(PREPARATION_TAG, "Seeding a custom status ('AMAZING') with the admin user.") + customStatusId = CustomStatusApi.upsertCustomGradeStatus(adminToken, name = "AMAZING", color = "#FF0000") + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val testAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) + + Log.d(PREPARATION_TAG, "Student submits the assignment.") + SubmissionsApi.submitCourseAssignment( + courseId = course.id, + studentToken = student.token, + assignmentId = testAssignment.id, + submissionType = SubmissionType.ONLINE_TEXT_ENTRY + ) + + Log.d(PREPARATION_TAG, "Teacher grades submission with custom status 'AMAZING'.") + SubmissionsApi.gradeSubmission( + teacherToken = teacher.token, + courseId = course.id, + assignmentId = testAssignment.id, + studentId = student.id, + postedGrade = "12", + customGradeStatusId = customStatusId + ) + + Log.d(STEP_TAG, "Login with user: '${parent.name}', login id: '${parent.loginId}'.") + tokenLogin(parent) + + 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 assignment '${testAssignment.name}' is displayed with the custom status 'AMAZING' on Course Details Page.") + courseDetailsPage.assertAssignmentStatus(testAssignment.name, "AMAZING") + + Log.d(STEP_TAG, "Click on the '${testAssignment.name}' assignment to open it's details.") + retryWithIncreasingDelay { + courseDetailsPage.clickAssignment(testAssignment.name) + } + + Log.d(ASSERTION_TAG, "Assert that the Assignment Details Page is displayed with the custom status 'AMAZING'.") + assignmentDetailsPage.assertCustomStatus("AMAZING") + assignmentDetailsPage.assertSubmissionAndRubricLabel() + } + + @After + fun tearDown() { + customStatusId?.let { + try { + Log.d(PREPARATION_TAG, "Cleaning up the custom status we created with '$it' ID previously because 3 is the maximum limit of custom statuses.") + CustomStatusApi.deleteCustomGradeStatus(adminToken, it) + Log.d(PREPARATION_TAG, "Successfully deleted custom status with ID: $it") + } catch (e: Exception) { + Log.e(PREPARATION_TAG, "Failed to delete custom status with ID: $it", e) + throw e + } + } ?: Log.w(PREPARATION_TAG, "No custom status ID to clean up - this might indicate the test failed during setup") + } +} \ 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 3df5436110..6fb621b08a 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 @@ -20,6 +20,8 @@ package com.instructure.parentapp.ui.pages.compose import androidx.compose.ui.graphics.Color 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.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule @@ -60,6 +62,11 @@ class CourseDetailsPage(private val composeTestRule: ComposeTestRule) { fun clickAssignment(assignmentName: String) { composeTestRule.onNode(hasTestTag("assignmentItem") and hasText(assignmentName)).performClick() + composeTestRule.waitForIdle() + } + + fun assertAssignmentStatus(assignmentName: String, status: String) { + composeTestRule.onNode(hasText(status) and hasAnyAncestor(hasAnyChild(hasText( assignmentName))), useUnmergedTree = true).assertIsDisplayed() } fun assertAssignmentLabelTextColor(assignmentName: String, expectedTextColor: Long) { diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoListModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoListModule.kt new file mode 100644 index 0000000000..c6681286e0 --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoListModule.kt @@ -0,0 +1,37 @@ +/* + * 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.parentapp.di.feature + +import com.instructure.pandautils.features.todolist.ToDoListViewModelBehavior +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + + +@Module +@InstallIn(ViewModelComponent::class) +class ToDoListModule { + + @Provides + fun provideToDoListViewModelBehavior(): ToDoListViewModelBehavior { + return object : ToDoListViewModelBehavior { + override fun updateWidget(forceRefresh: Boolean) = Unit + } + } +} \ No newline at end of file diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt index e665f3eaeb..ac1bfddfa6 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt @@ -20,6 +20,8 @@ package com.instructure.parentapp.di.feature import androidx.fragment.app.FragmentActivity import com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior import com.instructure.pandautils.features.calendartodo.details.ToDoRouter +import com.instructure.pandautils.features.todolist.DefaultToDoListRouter +import com.instructure.pandautils.features.todolist.ToDoListRouter import com.instructure.parentapp.features.calendartodo.ParentToDoRouter import com.instructure.parentapp.util.navigation.Navigation import dagger.Module @@ -36,6 +38,11 @@ class ToDoModule { fun provideToDoRouter(activity: FragmentActivity, navigation: Navigation): ToDoRouter { return ParentToDoRouter(activity, navigation) } + + @Provides + fun provideToDoListRouter(): ToDoListRouter { + return DefaultToDoListRouter() + } } @Module diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/assignment/details/ParentAssignmentDetailsSubmissionHandler.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/assignment/details/ParentAssignmentDetailsSubmissionHandler.kt index 70270adccf..c8d325f0b6 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/assignment/details/ParentAssignmentDetailsSubmissionHandler.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/assignment/details/ParentAssignmentDetailsSubmissionHandler.kt @@ -30,15 +30,19 @@ import java.io.File class ParentAssignmentDetailsSubmissionHandler( ) : AssignmentDetailsSubmissionHandler { override var isUploading: Boolean = false - override var lastSubmissionIsDraft: Boolean = false - override var lastSubmissionEntry: String? = null + override var isFailed: Boolean = false + override var lastSubmissionId: Long? = null override var lastSubmissionAssignmentId: Long? = null override var lastSubmissionSubmissionType: String? = null + override var lastSubmissionIsDraft: Boolean = false + override var lastSubmissionEntry: String? = null - override fun addAssignmentSubmissionObserver(context: Context, assignmentId: Long, userId: Long, resources: Resources, data: MutableLiveData, refreshAssignment: () -> Unit) = Unit + override fun addAssignmentSubmissionObserver(context: Context, assignmentId: Long, userId: Long, resources: Resources, data: MutableLiveData, refreshAssignment: () -> Unit, updateGradeCell: () -> Unit) = Unit override fun removeAssignmentSubmissionObserver() = Unit + override suspend fun ensureSubmissionStateIsCurrent(assignmentId: Long, userId: Long) = Unit + override fun uploadAudioSubmission(context: Context?, course: Course?, assignment: Assignment?, file: File?) = Unit override fun getVideoUri(fragment: FragmentActivity): Uri? = null 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 82e3335a2a..02550a9980 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 @@ -17,12 +17,16 @@ package com.instructure.parentapp.features.main +import android.Manifest import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.res.Configuration import android.net.Uri +import android.os.Build import android.os.Bundle import android.util.Log +import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment @@ -75,6 +79,8 @@ class MainActivity : BaseCanvasActivity(), OnUnreadCountInvalidated, Masqueradin private lateinit var navController: NavController + private val notificationsPermissionContract = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) @@ -82,6 +88,7 @@ class MainActivity : BaseCanvasActivity(), OnUnreadCountInvalidated, Masqueradin setupNavigation() handleQrMasquerading() scheduleAlarms() + requestNotificationsPermission() RatingDialog.showRatingDialog(this, AppType.PARENT) } @@ -182,6 +189,14 @@ class MainActivity : BaseCanvasActivity(), OnUnreadCountInvalidated, Masqueradin } } + private fun requestNotificationsPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + notificationsPermissionContract.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + override fun onStart() { super.onStart() EventBus.getDefault().register(this) 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 fca48d80f2..c0835c923e 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 @@ -88,6 +88,7 @@ class SimpleWebViewFragment : BaseCanvasFragment(), NavigationCallbacks { savedInstanceState?.let { binding.webView.restoreState(it) + binding.webView.enableAlgorithmicDarkening() } } diff --git a/apps/parent/src/test/java/com/instructure/parentapp/features/assignments/details/ParentAssignmentDetailsSubmissionHandlerTest.kt b/apps/parent/src/test/java/com/instructure/parentapp/features/assignments/details/ParentAssignmentDetailsSubmissionHandlerTest.kt index 99f69d9ed1..a917b325df 100644 --- a/apps/parent/src/test/java/com/instructure/parentapp/features/assignments/details/ParentAssignmentDetailsSubmissionHandlerTest.kt +++ b/apps/parent/src/test/java/com/instructure/parentapp/features/assignments/details/ParentAssignmentDetailsSubmissionHandlerTest.kt @@ -28,8 +28,10 @@ class ParentAssignmentDetailsSubmissionHandlerTest { @Test fun testUploadDefaultValues() { assertEquals(false, submissionHandler.isUploading) + assertEquals(false, submissionHandler.isFailed) assertEquals(false, submissionHandler.lastSubmissionIsDraft) assertEquals(null, submissionHandler.lastSubmissionEntry) + assertEquals(null, submissionHandler.lastSubmissionId) assertEquals(null, submissionHandler.lastSubmissionAssignmentId) assertEquals(null, submissionHandler.lastSubmissionSubmissionType) } diff --git a/apps/student/build.gradle b/apps/student/build.gradle index c9e11757c2..84db855b0c 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 = 282 - versionName = '8.3.0' + versionCode = 283 + versionName = '8.4.0' vectorDrawables.useSupportLibrary = true testInstrumentationRunner 'com.instructure.student.espresso.StudentHiltTestRunner' @@ -222,6 +222,10 @@ android { enableExperimentalClasspathAggregation = true } + ksp { + arg("room.schemaLocation", "$projectDir/schemas".toString()) + } + sourceSets { debug.assets.srcDirs += files("$projectDir/schemas".toString()) } @@ -338,9 +342,6 @@ dependencies { implementation Libs.GLANCE_APPWIDGET_PREVIEW } -// Comment out this line if the reporting logic starts going wonky. -gradle.addListener new TimingsListener(project) - apply plugin: 'com.google.gms.google-services' if (coverageEnabled) { diff --git a/apps/student/src/androidTest/assets/test.txt b/apps/student/src/androidTest/assets/test.txt new file mode 100644 index 0000000000..9bbedf7fb2 --- /dev/null +++ b/apps/student/src/androidTest/assets/test.txt @@ -0,0 +1 @@ +This is a test file for assignment submission. \ No newline at end of file diff --git a/apps/student/src/androidTest/assets/test_audio.mp3 b/apps/student/src/androidTest/assets/test_audio.mp3 new file mode 100644 index 0000000000..57add90168 Binary files /dev/null and b/apps/student/src/androidTest/assets/test_audio.mp3 differ diff --git a/apps/student/src/androidTest/assets/test_video.mp4 b/apps/student/src/androidTest/assets/test_video.mp4 new file mode 100644 index 0000000000..91352b4b07 Binary files /dev/null and b/apps/student/src/androidTest/assets/test_video.mp4 differ 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 86cf1607aa..5680ac92aa 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 @@ -297,6 +297,7 @@ class LoginE2ETest : StudentTest() { // Verify that students can sign into vanity domain @E2E @Test + @Stub @TestMetaData(Priority.MANDATORY, FeatureCategory.LOGIN, TestCategory.E2E) fun testVanityDomainLoginE2E() { // Create a Retrofit client for our vanity domain diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/CustomStatusesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/CustomStatusesE2ETest.kt new file mode 100644 index 0000000000..4872bca225 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/compose/CustomStatusesE2ETest.kt @@ -0,0 +1,102 @@ +package com.instructure.student.ui.e2e.compose + +import android.util.Log +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.E2E +import com.instructure.dataseeding.api.AssignmentsApi +import com.instructure.dataseeding.api.CustomStatusApi +import com.instructure.dataseeding.api.SubmissionsApi +import com.instructure.dataseeding.model.GradingType +import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.util.CanvasNetworkAdapter.adminToken +import com.instructure.dataseeding.util.days +import com.instructure.dataseeding.util.fromNow +import com.instructure.dataseeding.util.iso8601 +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.After +import org.junit.Test + +@HiltAndroidTest +class CustomStatusesE2ETest: StudentComposeTest() { + + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + private var customStatusId: String? = null + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CUSTOM_STATUSES, TestCategory.E2E) + fun testCustomStatusesE2E() { + + 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 a custom status ('AMAZING') with the admin user.") + customStatusId = CustomStatusApi.upsertCustomGradeStatus(adminToken, name = "AMAZING", color = "#FF0000") + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val testAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) + + Log.d(PREPARATION_TAG, "Student submits the assignment.") + SubmissionsApi.submitCourseAssignment( + courseId = course.id, + studentToken = student.token, + assignmentId = testAssignment.id, + submissionType = SubmissionType.ONLINE_TEXT_ENTRY + ) + + Log.d(PREPARATION_TAG, "Teacher grades submission with custom status 'AMAZING'.") + SubmissionsApi.gradeSubmission( + teacherToken = teacher.token, + courseId = course.id, + assignmentId = testAssignment.id, + studentId = student.id, + postedGrade = "12", + customGradeStatusId = customStatusId + ) + + Log.d(STEP_TAG, "Login with user: '${student.name}', login id: '${student.loginId}'.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select course: '${course.name}'.") + dashboardPage.selectCourse(course) + + Log.d(STEP_TAG, "Navigate to course Assignments Page.") + courseBrowserPage.selectAssignments() + + Log.d(ASSERTION_TAG, "Assert that our assignments are present, along with any grade/date info.") + assignmentListPage.assertHasAssignment(testAssignment, assignmentStatus = "AMAZING") + + Log.d(STEP_TAG, "Click on assignment '${testAssignment.name}'.") + assignmentListPage.clickAssignment(testAssignment) + + assignmentDetailsPage.assertCustomStatus("AMAZING") + assignmentDetailsPage.assertSubmissionAndRubricLabel() + } + + @After + fun tearDown() { + customStatusId?.let { + try { + Log.d(PREPARATION_TAG, "Cleaning up the custom status we created with '$it' ID previously because 3 is the maximum limit of custom statuses.") + CustomStatusApi.deleteCustomGradeStatus(adminToken, it) + Log.d(PREPARATION_TAG, "Successfully deleted custom status with ID: $it") + } catch (e: Exception) { + Log.e(PREPARATION_TAG, "Failed to delete custom status with ID: $it", e) + throw e + } + } ?: Log.w(PREPARATION_TAG, "No custom status ID to clean up - this might indicate the test failed during setup") + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt index 19384887e6..2a7c994f07 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt @@ -15,8 +15,21 @@ */ package com.instructure.student.ui.interaction +import android.Manifest +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore import androidx.compose.ui.platform.ComposeView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiSelector import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils import com.google.android.apps.common.testing.accessibility.framework.checks.SpeakableTextPresentCheck import com.instructure.canvas.espresso.FeatureCategory @@ -31,6 +44,7 @@ import com.instructure.canvas.espresso.mockcanvas.addAssignmentsToGroups import com.instructure.canvas.espresso.mockcanvas.addSubmissionForAssignment import com.instructure.canvas.espresso.mockcanvas.fakes.FakeCustomGradeStatusesManager import com.instructure.canvas.espresso.mockcanvas.init +import com.instructure.canvas.espresso.refresh import com.instructure.canvasapi2.di.graphql.CustomGradeStatusModule import com.instructure.canvasapi2.managers.graphql.CustomGradeStatusesManager import com.instructure.canvasapi2.models.Assignment @@ -49,6 +63,7 @@ import dagger.hilt.android.testing.UninstallModules import org.hamcrest.Matchers import org.junit.Assert.assertNotNull import org.junit.Test +import java.io.File import java.util.Calendar @HiltAndroidTest @@ -63,9 +78,7 @@ class AssignmentDetailsInteractionTest : StudentComposeTest() { @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION, SecondaryFeatureCategory.SUBMISSIONS_ONLINE_URL) - fun testSubmission_submitAssignment() { - // TODO - Test submitting for each submission type - // For now, I'm going to just test one submission type + fun testSubmission_submitOnlineURL() { val data = MockCanvas.init( studentCount = 1, courseCount = 1 @@ -89,6 +102,246 @@ class AssignmentDetailsInteractionTest : StudentComposeTest() { assignmentDetailsPage.assertStatusSubmitted() } + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION, SecondaryFeatureCategory.SUBMISSIONS_TEXT_ENTRY) + fun testSubmission_submitTextEntry() { + val data = MockCanvas.init( + studentCount = 1, + courseCount = 1 + ) + + val course = data.courses.values.first() + val student = data.students[0] + val token = data.tokenFor(student)!! + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY)) + data.addSubmissionForAssignment( + assignmentId = assignment.id, + userId = data.users.values.first().id, + type = Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString + ) + tokenLogin(data.domain, token, student) + routeTo("courses/${course.id}/assignments", data.domain) + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickSubmit() + textSubmissionUploadPage.typeText("This is my test submission text.") + textSubmissionUploadPage.clickOnSubmitButton() + assignmentDetailsPage.assertStatusSubmitted() + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION, SecondaryFeatureCategory.SUBMISSIONS_FILE_UPLOAD) + fun testSubmission_submitFileUpload() { + val data = MockCanvas.init( + studentCount = 1, + courseCount = 1 + ) + + val course = data.courses.values.first() + val student = data.students[0] + val token = data.tokenFor(student)!! + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_UPLOAD)) + data.addSubmissionForAssignment( + assignmentId = assignment.id, + userId = data.users.values.first().id, + type = Assignment.SubmissionType.ONLINE_UPLOAD.apiString + ) + tokenLogin(data.domain, token, student) + routeTo("courses/${course.id}/assignments", data.domain) + + val fileName = "test.txt" + Intents.init() + try { + stubFilePickerIntent(fileName) + setupFileOnDevice(fileName) + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickSubmit() + pickerSubmissionUploadPage.chooseDevice() + pickerSubmissionUploadPage.waitForSubmitButtonToAppear() + pickerSubmissionUploadPage.assertFileDisplayed(fileName) + pickerSubmissionUploadPage.submit() + assignmentDetailsPage.assertStatusSubmitted() + } finally { + Intents.release() + } + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION, SecondaryFeatureCategory.SUBMISSIONS_MEDIA_RECORDING) + fun testSubmission_submitMediaRecordingChooseMediaFile() { + val data = MockCanvas.init( + studentCount = 1, + courseCount = 1 + ) + + val course = data.courses.values.first() + val student = data.students[0] + val token = data.tokenFor(student)!! + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.MEDIA_RECORDING)) + data.addSubmissionForAssignment( + assignmentId = assignment.id, + userId = data.users.values.first().id, + type = Assignment.SubmissionType.MEDIA_RECORDING.apiString + ) + tokenLogin(data.domain, token, student) + routeTo("courses/${course.id}/assignments", data.domain) + + val activity = activityRule.activity + grantPermissions(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) + + val fileName = "test_video.mp4" + copyAssetFileToExternalCache(activity, fileName) + + val resultData = Intent() + val dir = activity.externalCacheDir + val file = File(dir?.path, fileName) + val uri = Uri.fromFile(file) + resultData.data = uri + val activityResult = Instrumentation.ActivityResult(Activity.RESULT_OK, resultData) + + Intents.init() + try { + Intents.intending( + Matchers.anyOf( + IntentMatchers.hasAction(Intent.ACTION_GET_CONTENT), + IntentMatchers.hasAction(Intent.ACTION_PICK), + IntentMatchers.hasAction(Intent.ACTION_OPEN_DOCUMENT) + ) + ).respondWith(activityResult) + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickSubmit() + + onView(withId(R.id.submissionEntryMediaFile)).perform(click()) + + pickerSubmissionUploadPage.waitForSubmitButtonToAppear() + pickerSubmissionUploadPage.assertFileDisplayed(fileName) + pickerSubmissionUploadPage.submit() + assignmentDetailsPage.assertStatusSubmitted() + } finally { + Intents.release() + } + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION, SecondaryFeatureCategory.SUBMISSIONS_MEDIA_RECORDING) + fun testSubmission_submitMediaRecordingRecordVideo() { + val data = MockCanvas.init( + studentCount = 1, + courseCount = 1 + ) + + val course = data.courses.values.first() + val student = data.students[0] + val token = data.tokenFor(student)!! + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.MEDIA_RECORDING)) + data.addSubmissionForAssignment( + assignmentId = assignment.id, + userId = data.users.values.first().id, + type = Assignment.SubmissionType.MEDIA_RECORDING.apiString + ) + tokenLogin(data.domain, token, student) + routeTo("courses/${course.id}/assignments", data.domain) + + val activity = activityRule.activity + grantPermissions(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) + + val testVideoFile = "test_video.mp4" + copyAssetFileToExternalCache(activity, testVideoFile) + + var capturedVideoUri: Uri? = null + + Intents.init() + Intents.intending( + Matchers.allOf( + IntentMatchers.hasAction(MediaStore.ACTION_VIDEO_CAPTURE), + IntentMatchers.hasExtraWithKey(MediaStore.EXTRA_OUTPUT) + ) + ).respondWithFunction { intent -> + val outputUri = intent.extras?.get(MediaStore.EXTRA_OUTPUT) as? Uri + capturedVideoUri = outputUri + if (outputUri != null) { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val dir = context.externalCacheDir + val sampleFile = File(dir, testVideoFile) + if (outputUri.scheme == "file") { + val destFile = File(outputUri.path!!) + destFile.parentFile?.mkdirs() + sampleFile.copyTo(destFile, overwrite = true) + } else if (outputUri.scheme == "content") { + context.contentResolver.openOutputStream(outputUri)?.use { outputStream -> + sampleFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } + } + Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) + } + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickSubmit() + onView(withId(R.id.submissionEntryVideo)).perform(click()) + + Intents.release() + + pickerSubmissionUploadPage.waitForSubmitButtonToAppear() + + val fileName = File(capturedVideoUri!!.path!!).name + pickerSubmissionUploadPage.assertFileDisplayed(fileName) + pickerSubmissionUploadPage.submit() + + assignmentDetailsPage.assertStatusSubmitted() + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION, SecondaryFeatureCategory.SUBMISSIONS_MEDIA_RECORDING) + fun testSubmission_submitMediaRecordingRecordAudio() { + val data = MockCanvas.init( + studentCount = 1, + courseCount = 1 + ) + + val course = data.courses.values.first() + val student = data.students[0] + val token = data.tokenFor(student)!! + val assignment = data.addAssignment(courseId = course.id, submissionTypeList = listOf(Assignment.SubmissionType.MEDIA_RECORDING)) + data.addSubmissionForAssignment( + assignmentId = assignment.id, + userId = data.users.values.first().id, + type = Assignment.SubmissionType.MEDIA_RECORDING.apiString + ) + tokenLogin(data.domain, token, student) + routeTo("courses/${course.id}/assignments", data.domain) + + val activity = activityRule.activity + grantPermissions(Manifest.permission.RECORD_AUDIO) + + val testAudioFileName = "test_audio.mp3" + copyAssetFileToExternalCache(activity, testAudioFileName) + + val context = InstrumentationRegistry.getInstrumentation().targetContext + val recordingFile = File(context.externalCacheDir, "audio.amr") + val testAudioFile = File(context.externalCacheDir, testAudioFileName) + testAudioFile.copyTo(recordingFile, overwrite = true) + + assignmentListPage.clickAssignment(assignment) + assignmentDetailsPage.clickSubmit() + onView(withId(R.id.submissionEntryAudio)).perform(click()) + + device.findObject(UiSelector().resourceIdMatches(".*recordAudioButton")).click() + + testAudioFile.copyTo(recordingFile, overwrite = true) + + device.findObject(UiSelector().resourceIdMatches(".*stopButton")).click() + + device.findObject(UiSelector().resourceIdMatches(".*sendAudioButton")).click() + + refresh() + assignmentDetailsPage.assertStatusSubmitted() + } + @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testSubmissionStatus_NotSubmitted() { @@ -694,6 +947,16 @@ class AssignmentDetailsInteractionTest : StudentComposeTest() { return assignment } + private fun grantPermissions(vararg permissions: String) { + val activity = activityRule.activity + permissions.forEach { permission -> + InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission( + activity.packageName, + permission + ) + } + } + override fun enableAndConfigureAccessibilityChecks() { extraAccessibilitySupressions = Matchers.allOf( AccessibilityCheckResultUtils.matchesCheck( 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 4720757b45..beafbbc822 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 @@ -38,7 +38,7 @@ class LoginInteractionTest : StudentTest() { if(isTabletDevice()) loginFindSchoolPage.assertHintText(R.string.schoolInstructureCom) else loginFindSchoolPage.assertHintText(R.string.loginHint) - loginFindSchoolPage.enterDomain("harv") + loginFindSchoolPage.enterDomain("harvest") loginFindSchoolPage.assertSchoolSearchResults("City Harvest Church (Singapore)") } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/StudentToDoListInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/StudentToDoListInteractionTest.kt new file mode 100644 index 0000000000..5e876777c3 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/StudentToDoListInteractionTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2025 - 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.student.ui.interaction + +import com.instructure.canvas.espresso.common.interaction.ToDoListInteractionTest +import com.instructure.canvas.espresso.common.pages.AssignmentDetailsPage +import com.instructure.canvas.espresso.mockcanvas.MockCanvas +import com.instructure.canvas.espresso.mockcanvas.fakes.FakeCustomGradeStatusesManager +import com.instructure.canvas.espresso.mockcanvas.init +import com.instructure.canvasapi2.di.graphql.CustomGradeStatusModule +import com.instructure.canvasapi2.managers.graphql.CustomGradeStatusesManager +import com.instructure.canvasapi2.models.User +import com.instructure.espresso.ModuleItemInteractions +import com.instructure.student.BuildConfig +import com.instructure.student.activity.LoginActivity +import com.instructure.student.ui.pages.classic.DashboardPage +import com.instructure.student.ui.utils.StudentActivityTestRule +import com.instructure.student.ui.utils.extensions.tokenLogin +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules + +@HiltAndroidTest +@UninstallModules(CustomGradeStatusModule::class) +class StudentToDoListInteractionTest : ToDoListInteractionTest() { + + @BindValue + @JvmField + val customGradeStatusesManager: CustomGradeStatusesManager = FakeCustomGradeStatusesManager() + + override val isTesting = BuildConfig.IS_TESTING + + override val activityRule = StudentActivityTestRule(LoginActivity::class.java) + + private val dashboardPage = DashboardPage() + private val assignmentDetailsPage = AssignmentDetailsPage(ModuleItemInteractions(), composeTestRule) + + override fun goToToDoList(data: MockCanvas) { + val student = data.students[0] + val token = data.tokenFor(student)!! + tokenLogin(data.domain, token, student) + + dashboardPage.clickTodoTab() + + composeTestRule.waitForIdle() + } + + override fun initData(): MockCanvas { + return MockCanvas.init( + studentCount = 1, + teacherCount = 1, + courseCount = 2, + favoriteCourseCount = 1 + ) + } + + override fun getLoggedInUser(): User { + return MockCanvas.data.students[0] + } + + override fun assertAssignmentDetailsTitle(title: String) { + assignmentDetailsPage.assertAssignmentTitle(title) + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt deleted file mode 100644 index 914f24e310..0000000000 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/TodoInteractionTest.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (C) 2019 - 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.student.ui.interaction - -import androidx.compose.ui.platform.ComposeView -import androidx.test.espresso.Espresso -import androidx.test.espresso.matcher.ViewMatchers -import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils -import com.google.android.apps.common.testing.accessibility.framework.checks.SpeakableTextPresentCheck -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.annotations.StubMultiAPILevel -import com.instructure.canvas.espresso.mockcanvas.MockCanvas -import com.instructure.canvas.espresso.mockcanvas.addAssignment -import com.instructure.canvas.espresso.mockcanvas.addQuizToCourse -import com.instructure.canvas.espresso.mockcanvas.fakes.FakeCustomGradeStatusesManager -import com.instructure.canvas.espresso.mockcanvas.init -import com.instructure.canvasapi2.di.graphql.CustomGradeStatusModule -import com.instructure.canvasapi2.managers.graphql.CustomGradeStatusesManager -import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.Course -import com.instructure.canvasapi2.models.Quiz -import com.instructure.dataseeding.util.days -import com.instructure.dataseeding.util.fromNow -import com.instructure.dataseeding.util.iso8601 -import com.instructure.student.ui.utils.StudentComposeTest -import com.instructure.student.ui.utils.extensions.tokenLogin -import dagger.hilt.android.testing.BindValue -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.UninstallModules -import org.hamcrest.Matchers -import org.junit.Test - -@HiltAndroidTest -@UninstallModules(CustomGradeStatusModule::class) -class TodoInteractionTest : StudentComposeTest() { - - @BindValue - @JvmField - val customGradeStatusesManager: CustomGradeStatusesManager = FakeCustomGradeStatusesManager() - - override fun displaysPageObjects() = Unit // Not used for interaction tests - - private lateinit var course: Course - private lateinit var assignment: Assignment - private lateinit var quiz: Quiz - - // Todo items should be clickable - @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.TODOS, TestCategory.INTERACTION) - fun testClick_todoItemClickable() { - - val data = goToTodos() - - todoPage.assertAssignmentDisplayed(assignment) - todoPage.selectAssignment(assignment) - assignmentDetailsPage.assertAssignmentDetails(assignment) - Espresso.pressBack() // Back to todo page - - todoPage.assertQuizDisplayed(quiz) - /* TODO: Check that the quiz is displayed if/when we can do so via WebView - todoPage.selectQuiz(quiz) - quizDetailsPage.assertQuizDisplayed(quiz,false,listOf()) - */ - } - - @Test - @StubLandscape("Stubbed because on lowres device in landscape mode, the space is too narrow to scroll properly. Will be refactored and running when we changed to non-lowres device on nightly runs.") - @StubMultiAPILevel("Somehow the 'OK' button within chooseFavoriteCourseFilter row is not clickable and not shown on the layout inspector as well.") - @TestMetaData(Priority.IMPORTANT, FeatureCategory.TODOS, TestCategory.INTERACTION) - fun testFilters() { - val data = goToTodos(courseCount = 2, favoriteCourseCount = 1) - val favoriteCourse = data.courses.values.first {course -> course.isFavorite} - val notFavoriteCourse = data.courses.values.first {course -> !course.isFavorite} - - val favoriteQuiz = data.courseQuizzes[favoriteCourse.id]!!.first() - val notFavoriteQuiz = data.courseQuizzes[notFavoriteCourse.id]!!.first() - - todoPage.assertQuizDisplayed(favoriteQuiz) - todoPage.assertQuizDisplayed(notFavoriteQuiz) - - todoPage.chooseFavoriteCourseFilter() - - todoPage.assertQuizDisplayed(favoriteQuiz) - todoPage.assertQuizNotDisplayed(notFavoriteQuiz) - - todoPage.clearFilter() - - todoPage.assertQuizDisplayed(favoriteQuiz) - todoPage.assertQuizDisplayed(notFavoriteQuiz) - } - - - // Seeds ToDos (assignment + quiz) for tomorrow and then navigates to the ToDo page - fun goToTodos(courseCount: Int = 1, favoriteCourseCount: Int = 1) : MockCanvas { - var data = MockCanvas.init( - studentCount = 1, - teacherCount = 1, - courseCount = courseCount, - favoriteCourseCount = favoriteCourseCount - ) - - val student = data.students.first() - for(course in data.courses.values) { - assignment = data.addAssignment( - courseId = course.id, - submissionTypeList = listOf(Assignment.SubmissionType.ONLINE_TEXT_ENTRY), - dueAt = 1.days.fromNow.iso8601 - ) - - quiz = data.addQuizToCourse( - course = course, - quizType = Quiz.TYPE_ASSIGNMENT, - dueAt = 1.days.fromNow.iso8601 - ) - } - - val token = data.tokenFor(student)!! - tokenLogin(data.domain, token, student) - - dashboardPage.waitForRender() - dashboardPage.clickTodoTab() - - return data - } - - override fun enableAndConfigureAccessibilityChecks() { - extraAccessibilitySupressions = Matchers.allOf( - AccessibilityCheckResultUtils.matchesCheck( - SpeakableTextPresentCheck::class.java - ), - AccessibilityCheckResultUtils.matchesViews( - ViewMatchers.withParent( - ViewMatchers.withClassName( - Matchers.equalTo(ComposeView::class.java.name) - ) - ) - ) - ) - - super.enableAndConfigureAccessibilityChecks() - } - -} diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/ConferenceListRenderTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/ConferenceListRenderTest.kt index 4f46583e3e..199746f2bb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/ConferenceListRenderTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/ConferenceListRenderTest.kt @@ -20,6 +20,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Group +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListItemViewState import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListRepositoryFragment import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListViewState @@ -81,7 +82,11 @@ class ConferenceListRenderTest : StudentRenderTest() { fun displaysListItems() { val tint = Color.BLUE val itemStates = listOf( - ConferenceListItemViewState.ConferenceHeader("Header 1"), + ConferenceListItemViewState.ConferenceHeader( + title = "Header 1", + headerType = ConferenceHeaderType.NEW_CONFERENCES, + isExpanded = true + ), ConferenceListItemViewState.ConferenceItem( tint = tint, title = "Conference 1", @@ -100,7 +105,11 @@ class ConferenceListRenderTest : StudentRenderTest() { conferenceId = 0, isJoinable = false ), - ConferenceListItemViewState.ConferenceHeader("Header 2"), + ConferenceListItemViewState.ConferenceHeader( + title = "Header 2", + headerType = ConferenceHeaderType.CONCLUDED_CONFERENCES, + isExpanded = true + ), ConferenceListItemViewState.ConferenceItem( tint = tint, title = "Conference 3", diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt new file mode 100644 index 0000000000..9633cedf30 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt @@ -0,0 +1,124 @@ +package com.instructure.student.ui.rendertests + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter +import com.instructure.student.features.dashboard.compose.DashboardScreenContent +import com.instructure.student.features.dashboard.compose.DashboardUiState +import kotlinx.coroutines.flow.MutableSharedFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DashboardScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val mockRouter = object : DashboardRouter { + override fun routeToGlobalAnnouncement(subject: String, message: String) {} + override fun routeToSubmissionDetails(canvasContext: CanvasContext, assignmentId: Long, attemptId: Long) {} + override fun routeToMyFiles(canvasContext: CanvasContext, folderId: Long) {} + override fun routeToSyncProgress() {} + } + + @Test + fun testDashboardScreenShowsLoadingState() { + val mockUiState = DashboardUiState( + loading = true, + error = null, + refreshing = false, + onRefresh = {}, + onRetry = {} + ) + + composeTestRule.setContent { + DashboardScreenContent( + uiState = mockUiState, + refreshSignal = MutableSharedFlow(), + snackbarMessageFlow = MutableSharedFlow(), + onShowSnackbar = { _, _, _ -> }, + router = mockRouter + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("loading").assertIsDisplayed() + } + + @Test + fun testDashboardScreenShowsErrorState() { + val mockUiState = DashboardUiState( + loading = false, + error = "An error occurred", + refreshing = false, + onRefresh = {}, + onRetry = {} + ) + + composeTestRule.setContent { + DashboardScreenContent( + uiState = mockUiState, + refreshSignal = MutableSharedFlow(), + snackbarMessageFlow = MutableSharedFlow(), + onShowSnackbar = { _, _, _ -> }, + router = mockRouter + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("errorContent").assertIsDisplayed() + } + + @Test + fun testDashboardScreenShowsEmptyState() { + val mockUiState = DashboardUiState( + loading = false, + error = null, + refreshing = false, + onRefresh = {}, + onRetry = {} + ) + + composeTestRule.setContent { + DashboardScreenContent( + uiState = mockUiState, + refreshSignal = MutableSharedFlow(), + snackbarMessageFlow = MutableSharedFlow(), + onShowSnackbar = { _, _, _ -> }, + router = mockRouter + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("emptyContent").assertIsDisplayed() + } + + @Test + fun testDashboardScreenShowsRefreshIndicator() { + val mockUiState = DashboardUiState( + loading = false, + error = null, + refreshing = true, + onRefresh = {}, + onRetry = {} + ) + + composeTestRule.setContent { + DashboardScreenContent( + uiState = mockUiState, + refreshSignal = MutableSharedFlow(), + snackbarMessageFlow = MutableSharedFlow(), + onShowSnackbar = { _, _, _ -> }, + router = mockRouter + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("dashboardPullRefreshIndicator").assertIsDisplayed() + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt index fe20ece105..6af794d925 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt @@ -20,6 +20,8 @@ package com.instructure.student.activity import android.os.Bundle import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.StatusCallback +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.PlannerAPI import com.instructure.canvasapi2.apis.UserAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.FeaturesManager @@ -31,6 +33,7 @@ import com.instructure.canvasapi2.models.Account import com.instructure.canvasapi2.models.CanvasColor import com.instructure.canvasapi2.models.CanvasTheme import com.instructure.canvasapi2.models.LaunchDefinition +import com.instructure.canvasapi2.models.PlannableType import com.instructure.canvasapi2.models.SelfRegistration import com.instructure.canvasapi2.models.TermsOfService import com.instructure.canvasapi2.models.UnreadConversationCount @@ -41,20 +44,29 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.ApiType import com.instructure.canvasapi2.utils.LinkHeaders import com.instructure.canvasapi2.utils.Logger +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils +import com.instructure.canvasapi2.utils.depaginate +import com.instructure.canvasapi2.utils.isInvited import com.instructure.canvasapi2.utils.pageview.PandataInfo import com.instructure.canvasapi2.utils.pageview.PandataManager +import com.instructure.canvasapi2.utils.toApiString import com.instructure.canvasapi2.utils.weave.StatusCallbackError import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.pandautils.dialogs.RatingDialog import com.instructure.pandautils.features.inbox.list.OnUnreadCountInvalidated +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity import com.instructure.pandautils.utils.AppType import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.LocaleUtils import com.instructure.pandautils.utils.SHA256 import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.filterByToDoFilters +import com.instructure.pandautils.utils.isComplete import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.toast import com.instructure.student.BuildConfig @@ -85,15 +97,28 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No @Inject lateinit var userApi: UserAPI.UsersInterface + @Inject + lateinit var plannerApi: PlannerAPI.PlannerInterface + @Inject lateinit var widgetLogger: WidgetLogger + @Inject + lateinit var toDoFilterDao: ToDoFilterDao + + @Inject + lateinit var apiPrefs: ApiPrefs + + @Inject + lateinit var courseApi: CourseAPI.CoursesInterface + private var loadInitialDataJob: Job? = null abstract fun gotLaunchDefinitions(launchDefinitions: List?) abstract fun updateUnreadCount(unreadCount: Int) abstract fun increaseUnreadCount(increaseBy: Int) abstract fun updateNotificationCount(notificationCount: Int) + abstract fun updateToDoCount(toDoCount: Int) abstract fun initialCoreDataLoadingComplete() override fun onCreate(savedInstanceState: Bundle?) { @@ -178,6 +203,10 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No getUnreadNotificationCount() + if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { + getToDoCount() + } + initialCoreDataLoadingComplete() } catch { initialCoreDataLoadingComplete() @@ -206,6 +235,43 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No } } + protected suspend fun getToDoCount() { + val todoFilters = toDoFilterDao.findByUser( + apiPrefs.fullDomain, + apiPrefs.user?.id.orDefault() + ) ?: ToDoFilterEntity(userDomain = apiPrefs.fullDomain, userId = apiPrefs.user?.id.orDefault()) + + val startDate = todoFilters.pastDateRange.calculatePastDateRange().toApiString() + val endDate = todoFilters.futureDateRange.calculateFutureDateRange().toApiString() + + val restParams = RestParams(isForceReadFromNetwork = true, usePerPageQueryParam = true) + val plannerItems = plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = restParams + ).depaginate { nextUrl -> + plannerApi.nextPagePlannerItems(nextUrl, restParams) + } + + val filteredCourses = if (todoFilters.favoriteCourses) { + val restParams = RestParams(isForceReadFromNetwork = true) + val allCourses = courseApi.getFirstPageCourses(restParams).depaginate { nextUrl -> + courseApi.next(nextUrl, restParams) + } + allCourses.dataOrNull?.filter { !it.accessRestrictedByDate && !it.isInvited() }.orEmpty() + } else { + emptyList() + } + + // Filter planner items - exclude announcements, assessment requests, completed items + val todoCount = plannerItems.dataOrNull + ?.filter { it.plannableType != PlannableType.ANNOUNCEMENT && it.plannableType != PlannableType.ASSESSMENT_REQUEST && !it.isComplete() } + ?.filterByToDoFilters(todoFilters, filteredCourses) + ?.count() ?: 0 + updateToDoCount(todoCount) + } + private fun getUnreadNotificationCount() { UnreadCountManager.getUnreadNotificationCount(object : StatusCallback>() { override fun onResponse(data: Call>, response: Response>) { 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 94d384b33d..176f5122bb 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 @@ -62,6 +62,8 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.Logger import com.instructure.canvasapi2.utils.MasqueradeHelper import com.instructure.canvasapi2.utils.Pronouns +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.canvasapi2.utils.weave.WeaveJob import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.catch @@ -79,6 +81,8 @@ import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.pandautils.analytics.OfflineAnalyticsManager import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.features.calendar.CalendarFragment +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.calendar.SharedCalendarAction import com.instructure.pandautils.features.calendarevent.details.EventFragment import com.instructure.pandautils.features.help.HelpDialogFragment import com.instructure.pandautils.features.inbox.compose.InboxComposeFragment @@ -89,6 +93,8 @@ import com.instructure.pandautils.features.notification.preferences.PushNotifica import com.instructure.pandautils.features.offline.sync.OfflineSyncHelper import com.instructure.pandautils.features.reminder.AlarmScheduler import com.instructure.pandautils.features.settings.SettingsFragment +import com.instructure.pandautils.features.todolist.OnToDoCountChanged +import com.instructure.pandautils.features.todolist.ToDoListFragment import com.instructure.pandautils.interfaces.NavigationCallbacks import com.instructure.pandautils.models.PushNotification import com.instructure.pandautils.receivers.PushExternalReceiver @@ -135,9 +141,9 @@ 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.DashboardFragment +import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.NotificationListFragment -import com.instructure.student.fragment.ToDoListFragment +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 import com.instructure.student.navigation.AccountMenuItem @@ -171,7 +177,7 @@ private const val BOTTOM_SCREENS_BUNDLE_KEY = "bottomScreens" @AndroidEntryPoint @Suppress("DELEGATED_MEMBER_HIDES_SUPERTYPE_OVERRIDE") class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog.OnMasqueradingSet, - FullScreenInteractions, ActivityCompat.OnRequestPermissionsResultCallback by PermissionReceiver() { + FullScreenInteractions, ActivityCompat.OnRequestPermissionsResultCallback by PermissionReceiver(), OnToDoCountChanged { private val binding by viewBinding(ActivityNavigationBinding::inflate) private lateinit var navigationDrawerBinding: NavigationDrawerBinding @@ -219,6 +225,9 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. @Inject lateinit var webViewAuthenticator: WebViewAuthenticator + @Inject + lateinit var calendarSharedEvents: CalendarSharedEvents + private var routeJob: WeaveJob? = null private var debounceJob: Job? = null private var drawerItemSelectedJob: Job? = null @@ -401,6 +410,33 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. scheduleAlarms() WidgetUpdater.updateWidgets() + + observeCalendarSharedEvents() + } + + private fun observeCalendarSharedEvents() { + lifecycleScope.launch { + calendarSharedEvents.events.collect { action -> + when (action) { + is SharedCalendarAction.RefreshToDoList -> { + handleToDoListRefresh() + } + + else -> {} // Ignore other actions + } + } + } + } + + private fun handleToDoListRefresh() { + // Update To Do badge + lifecycleScope.launch { + tryWeave { + getToDoCount() + } catch { + firebaseCrashlytics.recordException(it) + } + } } private fun logOfflineEvents(isOnline: Boolean) { @@ -537,7 +573,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } AppShortcutManager.APP_SHORTCUT_CALENDAR -> selectBottomNavFragment( CalendarFragment::class.java) - AppShortcutManager.APP_SHORTCUT_TODO -> selectBottomNavFragment(ToDoListFragment::class.java) + AppShortcutManager.APP_SHORTCUT_TODO -> selectBottomNavFragment(navigationBehavior.todoFragmentClass) AppShortcutManager.APP_SHORTCUT_NOTIFICATIONS -> selectBottomNavFragment(NotificationListFragment::class.java) AppShortcutManager.APP_SHORTCUT_INBOX -> { if (ApiPrefs.isStudentView) { @@ -789,7 +825,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. when (item.itemId) { R.id.bottomNavigationHome -> selectBottomNavFragment(navigationBehavior.homeFragmentClass) R.id.bottomNavigationCalendar -> selectBottomNavFragment(CalendarFragment::class.java) - R.id.bottomNavigationToDo -> selectBottomNavFragment(ToDoListFragment::class.java) + R.id.bottomNavigationToDo -> selectBottomNavFragment(navigationBehavior.todoFragmentClass) R.id.bottomNavigationNotifications -> selectBottomNavFragment(NotificationListFragment::class.java) R.id.bottomNavigationInbox -> { if (ApiPrefs.isStudentView) { @@ -812,7 +848,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. R.id.bottomNavigationHome -> abortReselect = currentFragmentClass.isAssignableFrom(navigationBehavior.homeFragmentClass) R.id.bottomNavigationCalendar -> abortReselect = currentFragmentClass.isAssignableFrom( CalendarFragment::class.java) - R.id.bottomNavigationToDo -> abortReselect = currentFragmentClass.isAssignableFrom(ToDoListFragment::class.java) + R.id.bottomNavigationToDo -> abortReselect = currentFragmentClass.isAssignableFrom(navigationBehavior.todoFragmentClass) R.id.bottomNavigationNotifications -> abortReselect = currentFragmentClass.isAssignableFrom(NotificationListFragment::class.java) R.id.bottomNavigationInbox -> abortReselect = currentFragmentClass.isAssignableFrom(InboxFragment::class.java) } @@ -822,7 +858,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. when (item.itemId) { R.id.bottomNavigationHome -> selectBottomNavFragment(navigationBehavior.homeFragmentClass) R.id.bottomNavigationCalendar -> selectBottomNavFragment(CalendarFragment::class.java) - R.id.bottomNavigationToDo -> selectBottomNavFragment(ToDoListFragment::class.java) + R.id.bottomNavigationToDo -> selectBottomNavFragment(navigationBehavior.todoFragmentClass) R.id.bottomNavigationNotifications -> selectBottomNavFragment(NotificationListFragment::class.java) R.id.bottomNavigationInbox -> { if (ApiPrefs.isStudentView) { @@ -875,6 +911,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. is EventFragment -> setBottomBarItemSelected(R.id.bottomNavigationCalendar) //To-do is ToDoListFragment -> setBottomBarItemSelected(R.id.bottomNavigationToDo) + is OldToDoListFragment -> setBottomBarItemSelected(R.id.bottomNavigationToDo) //Notifications is NotificationListFragment-> { setBottomBarItemSelected(if(fragment.isCourseOrGroup()) R.id.bottomNavigationHome @@ -1008,7 +1045,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. private fun selectBottomNavFragment(fragmentClass: Class) { val selectedFragment = supportFragmentManager.findFragmentByTag(fragmentClass.name) - (topFragment as? DashboardFragment)?.cancelCardDrag() + (topFragment as? OldDashboardFragment)?.cancelCardDrag() if (selectedFragment == null) { val fragment = createBottomNavFragment(fragmentClass.name) @@ -1265,6 +1302,14 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. updateBottomBarBadge(R.id.bottomNavigationNotifications, notificationCount, R.plurals.a11y_notificationsUnreadCount) } + override fun updateToDoCount(toDoCount: Int) { + updateBottomBarBadge(R.id.bottomNavigationToDo, toDoCount, R.plurals.a11y_todoBadgeCount) + } + + override fun onToDoCountChanged(count: Int) { + updateToDoCount(count) + } + private fun updateBottomBarBadge(@IdRes menuItemId: Int, count: Int, @PluralsRes quantityContentDescription: Int? = null) = with(binding) { if (count > 0) { bottomBar.getOrCreateBadge(menuItemId).number = count @@ -1306,9 +1351,17 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. val route = CalendarFragment.makeRoute() CalendarFragment.newInstance(route) } - ToDoListFragment::class.java.name -> { - val route = ToDoListFragment.makeRoute(ApiPrefs.user!!) - ToDoListFragment.newInstance(route) + navigationBehavior.todoFragmentClass.name -> { + val route = if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { + ToDoListFragment.makeRoute(ApiPrefs.user!!) + } else { + OldToDoListFragment.makeRoute(ApiPrefs.user!!) + } + if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { + ToDoListFragment.newInstance(route) + } else { + OldToDoListFragment.newInstance(route) + } } NotificationListFragment::class.java.name -> { val route = NotificationListFragment.makeRoute(ApiPrefs.user!!) diff --git a/apps/student/src/main/java/com/instructure/student/di/WidgetModule.kt b/apps/student/src/main/java/com/instructure/student/di/WidgetModule.kt index 77efc2164a..b95ef2992b 100644 --- a/apps/student/src/main/java/com/instructure/student/di/WidgetModule.kt +++ b/apps/student/src/main/java/com/instructure/student/di/WidgetModule.kt @@ -26,7 +26,7 @@ import com.instructure.canvasapi2.apis.UserAPI import com.instructure.canvasapi2.managers.FeaturesManager import com.instructure.canvasapi2.utils.Analytics import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.pandautils.room.calendar.daos.CalendarFilterDao +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.student.widget.WidgetLogger import com.instructure.student.widget.WidgetUpdater @@ -69,9 +69,10 @@ class WidgetModule { fun provideToDoWidgetRepository( plannerApi: PlannerAPI.PlannerInterface, coursesApi: CourseAPI.CoursesInterface, - calendarFilterDao: CalendarFilterDao + toDoFilterDao: ToDoFilterDao, + apiPrefs: ApiPrefs ): ToDoWidgetRepository { - return ToDoWidgetRepository(plannerApi, coursesApi, calendarFilterDao) + return ToDoWidgetRepository(plannerApi, coursesApi, toDoFilterDao, apiPrefs) } @Provides diff --git a/apps/student/src/main/java/com/instructure/student/di/feature/ToDoListModule.kt b/apps/student/src/main/java/com/instructure/student/di/feature/ToDoListModule.kt new file mode 100644 index 0000000000..ad2983f7cd --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/di/feature/ToDoListModule.kt @@ -0,0 +1,64 @@ +/* + * 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.student.di.feature + +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.todolist.ToDoListRouter +import com.instructure.pandautils.features.todolist.ToDoListViewModelBehavior +import com.instructure.student.features.todolist.StudentToDoListRouter +import com.instructure.student.features.todolist.StudentToDoListViewModelBehavior +import com.instructure.student.widget.WidgetUpdater +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.FragmentComponent +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.qualifiers.ApplicationContext + + +@Module +@InstallIn(FragmentComponent::class) +class ToDoListModule { + + @Provides + fun provideToDoListRouter( + activity: FragmentActivity, + fragment: Fragment, + calendarSharedEvents: CalendarSharedEvents + ): ToDoListRouter { + return StudentToDoListRouter(activity, fragment, calendarSharedEvents) + } +} + +@Module +@InstallIn(ViewModelComponent::class) +class ToDoListViewModelModule { + + @Provides + fun provideToDoListViewModelBehavior( + @ApplicationContext context: Context, + widgetUpdater: WidgetUpdater, + appWidgetManager: AppWidgetManager + ): ToDoListViewModelBehavior { + return StudentToDoListViewModelBehavior(context, widgetUpdater, appWidgetManager) + } +} diff --git a/apps/student/src/main/java/com/instructure/student/dialog/BookmarkCreationDialog.kt b/apps/student/src/main/java/com/instructure/student/dialog/BookmarkCreationDialog.kt index c84699d7f2..ea0c25da43 100644 --- a/apps/student/src/main/java/com/instructure/student/dialog/BookmarkCreationDialog.kt +++ b/apps/student/src/main/java/com/instructure/student/dialog/BookmarkCreationDialog.kt @@ -163,6 +163,10 @@ class BookmarkCreationDialog : BaseCanvasAppCompatDialogFragment() { bookmark.getQueryParamForBookmark) } + // Strip any remaining placeholder segments (e.g., /:sliding_tab_type/:submission_id) + // that weren't replaced during URL generation + bookmarkUrl = bookmarkUrl?.replace(Regex("/:[^/?#]+"), "") + Analytics.trackBookmarkSelected(context, peakingFragment::class.java.simpleName + " " + topFragment::class.java.simpleName) if(bookmarkUrl != null) { @@ -178,6 +182,10 @@ class BookmarkCreationDialog : BaseCanvasAppCompatDialogFragment() { bookmarkUrl = RouteMatcher.generateUrl(bookmark.canvasContext!!.type, topFragment::class.java, topFragment.bookmark.getParamForBookmark) } + // Strip any remaining placeholder segments (e.g., /:sliding_tab_type/:submission_id) + // that weren't replaced during URL generation + bookmarkUrl = bookmarkUrl?.replace(Regex("/:[^/?#]+"), "") + Analytics.trackBookmarkSelected(context, topFragment::class.java.simpleName) if(bookmarkUrl != null) { diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/StudentAssignmentDetailsSubmissionHandler.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/StudentAssignmentDetailsSubmissionHandler.kt index c477b1e3eb..f394bb0cec 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/StudentAssignmentDetailsSubmissionHandler.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/StudentAssignmentDetailsSubmissionHandler.kt @@ -18,7 +18,6 @@ package com.instructure.student.features.assignments.details import android.content.Context import android.content.res.Resources import android.net.Uri -import android.os.Build import androidx.fragment.app.FragmentActivity import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -39,7 +38,9 @@ import com.instructure.student.mobius.assignmentDetails.uploadAudioRecording import com.instructure.student.mobius.common.ui.SubmissionHelper import com.instructure.pandautils.room.studentdb.StudentDb import com.instructure.pandautils.room.studentdb.entities.CreateSubmissionEntity +import com.instructure.pandautils.room.studentdb.entities.SubmissionState import com.instructure.student.util.getStudioLTITool +import kotlinx.coroutines.runBlocking import java.io.File import java.util.Date @@ -48,6 +49,8 @@ class StudentAssignmentDetailsSubmissionHandler( private val studentDb: StudentDb ) : AssignmentDetailsSubmissionHandler { override var isUploading: Boolean = false + override var isFailed: Boolean = false + override var lastSubmissionId: Long? = null override var lastSubmissionAssignmentId: Long? = null override var lastSubmissionSubmissionType: String? = null override var lastSubmissionIsDraft: Boolean = false @@ -64,10 +67,35 @@ class StudentAssignmentDetailsSubmissionHandler( resources: Resources, data: MutableLiveData, refreshAssignment: () -> Unit, + updateGradeCell: () -> Unit ) { + runBlocking { + val submissions = studentDb.submissionDao().findSubmissionsByAssignmentId(assignmentId, userId) + val initialSubmission = submissions.lastOrNull() + + if (initialSubmission != null) { + val hasFailedSubmission = initialSubmission.submissionState == SubmissionState.FAILED || initialSubmission.errorFlag + val isActivelyUploading = !initialSubmission.isDraft && initialSubmission.submissionState.isActive + + if (hasFailedSubmission) { + isFailed = true + isUploading = false + } else if (isActivelyUploading) { + isUploading = true + isFailed = false + } + + lastSubmissionId = initialSubmission.id + lastSubmissionAssignmentId = initialSubmission.assignmentId + lastSubmissionSubmissionType = initialSubmission.submissionType + lastSubmissionIsDraft = initialSubmission.isDraft + lastSubmissionEntry = initialSubmission.submissionEntry + } + } + submissionLiveData = studentDb.submissionDao().findSubmissionsByAssignmentIdLiveData(assignmentId, userId) - setupObserver(context, resources, data, refreshAssignment) + setupObserver(context, resources, data, refreshAssignment, updateGradeCell) submissionObserver?.let { observer -> submissionLiveData?.observeForever(observer) @@ -80,6 +108,45 @@ class StudentAssignmentDetailsSubmissionHandler( } } + override suspend fun ensureSubmissionStateIsCurrent(assignmentId: Long, userId: Long) { + val submissions = studentDb.submissionDao().findSubmissionsByAssignmentId(assignmentId, userId) + val submission = submissions.lastOrNull() + + if (submission != null) { + val hasFailedSubmission = submission.submissionState == SubmissionState.FAILED || submission.errorFlag + val isActivelyUploading = !submission.isDraft && submission.submissionState.isActive + + when { + hasFailedSubmission -> { + isFailed = true + isUploading = false + } + isActivelyUploading -> { + isUploading = true + isFailed = false + } + else -> { + isFailed = false + isUploading = false + } + } + + lastSubmissionId = submission.id + lastSubmissionAssignmentId = submission.assignmentId + lastSubmissionSubmissionType = submission.submissionType + lastSubmissionIsDraft = submission.isDraft + lastSubmissionEntry = submission.submissionEntry + } else { + isFailed = false + isUploading = false + lastSubmissionId = null + lastSubmissionAssignmentId = null + lastSubmissionSubmissionType = null + lastSubmissionIsDraft = false + lastSubmissionEntry = null + } + } + override fun uploadAudioSubmission(context: Context?, course: Course?, assignment: Assignment?, file: File?) { if (context != null && file != null && assignment != null && course != null) { uploadAudioRecording(submissionHelper, file, assignment, course) @@ -103,9 +170,11 @@ class StudentAssignmentDetailsSubmissionHandler( resources: Resources, data: MutableLiveData, refreshAssignment: () -> Unit, + updateGradeCell: () -> Unit ) { submissionObserver = Observer> { submissions -> val submission = submissions.lastOrNull() + lastSubmissionId = submission?.id lastSubmissionAssignmentId = submission?.assignmentId lastSubmissionSubmissionType = submission?.submissionType lastSubmissionIsDraft = submission?.isDraft ?: false @@ -117,9 +186,13 @@ class StudentAssignmentDetailsSubmissionHandler( data.value?.hasDraft = isDraft data.value?.notifyPropertyChanged(BR.hasDraft) + val isActivelyUploading = !isDraft && dbSubmission.submissionState.isActive + val hasFailedSubmission = dbSubmission.submissionState == SubmissionState.FAILED || dbSubmission.errorFlag val dateString = (dbSubmission.lastActivityDate?.toInstant()?.toEpochMilli()?.let { Date(it) } ?: Date()).toFormattedString() - if (!isDraft && !isUploading) { + + if (isActivelyUploading && !isUploading) { isUploading = true + isFailed = false data.value?.attempts = attempts?.toMutableList()?.apply { add( 0, AssignmentDetailsAttemptItemViewModel( @@ -133,9 +206,14 @@ class StudentAssignmentDetailsSubmissionHandler( }.orEmpty() data.value?.notifyPropertyChanged(BR.attempts) } - if (isUploading && submission.errorFlag) { + + if (hasFailedSubmission && !isFailed) { + isUploading = false + isFailed = true data.value?.attempts = attempts?.toMutableList()?.apply { - if (isNotEmpty()) removeAt(0) + if (isNotEmpty() && firstOrNull()?.data?.isUploading == true) { + removeAt(0) + } add(0, AssignmentDetailsAttemptItemViewModel( AssignmentDetailsAttemptViewData( resources.getString(R.string.attempt, attempts.size), @@ -146,10 +224,19 @@ class StudentAssignmentDetailsSubmissionHandler( ) }.orEmpty() data.value?.notifyPropertyChanged(BR.attempts) + updateGradeCell() + } + + if (dbSubmission.submissionState == SubmissionState.COMPLETED && isUploading) { + isUploading = false + isFailed = false + refreshAssignment() + context.toast(R.string.submissionSuccessTitle) } } ?: run { if (isUploading) { isUploading = false + isFailed = false refreshAssignment() context.toast(R.string.submissionSuccessTitle) } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/datasource/AssignmentDetailsNetworkDataSource.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/datasource/AssignmentDetailsNetworkDataSource.kt index 22352cb3aa..5a3da42077 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/datasource/AssignmentDetailsNetworkDataSource.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/datasource/AssignmentDetailsNetworkDataSource.kt @@ -63,9 +63,9 @@ class AssignmentDetailsNetworkDataSource( return quizInterface.getQuiz(courseId, quizId, params).dataOrThrow } - override suspend fun getExternalToolLaunchUrl(courseId: Long, externalToolId: Long, assignmentId: Long, forceNetwork: Boolean): LTITool { + override suspend fun getExternalToolLaunchUrl(courseId: Long, externalToolId: Long, assignmentId: Long, forceNetwork: Boolean): LTITool? { val params = RestParams(isForceReadFromNetwork = forceNetwork) - return assignmentInterface.getExternalToolLaunchUrl(courseId, externalToolId, assignmentId, restParams = params).dataOrThrow + return assignmentInterface.getExternalToolLaunchUrl(courseId, externalToolId, assignmentId, restParams = params).dataOrNull } override suspend fun getLtiFromAuthenticationUrl(url: String, forceNetwork: Boolean): LTITool { diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt new file mode 100644 index 0000000000..5e17ba3868 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2024 - 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.dashboard.compose + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +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.features.dashboard.notifications.DashboardRouter +import com.instructure.student.fragment.ParentFragment +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class DashboardFragment : ParentFragment() { + + @Inject + lateinit var router: DashboardRouter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + applyTheme() + return ComposeView(requireContext()).apply { + setContent { + CanvasTheme { + DashboardScreen(router = router) + } + } + } + } + + override fun title(): String = "" + + override fun applyTheme() { + navigation?.attachNavigationDrawer(this, null) + } + + companion object { + fun makeRoute(canvasContext: CanvasContext?) = + Route(DashboardFragment::class.java, canvasContext) + + fun newInstance(route: Route): DashboardFragment { + val fragment = DashboardFragment() + fragment.arguments = route.arguments + return fragment + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..e4dfb14d3f --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2024 - 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.dashboard.compose + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.instructure.pandautils.compose.SnackbarMessage +import com.instructure.pandautils.compose.composables.CanvasThemedAppBar +import com.instructure.pandautils.compose.composables.EmptyContent +import com.instructure.pandautils.compose.composables.ErrorContent +import com.instructure.pandautils.compose.composables.Loading +import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.courseinvitation.CourseInvitationsWidget +import com.instructure.pandautils.features.dashboard.widget.welcome.WelcomeWidget +import com.instructure.pandautils.features.dashboard.widget.institutionalannouncements.InstitutionalAnnouncementsWidget +import com.instructure.student.R +import com.instructure.student.activity.NavigationActivity +import kotlinx.coroutines.flow.SharedFlow + +@Composable +fun DashboardScreen(router: DashboardRouter) { + val viewModel: DashboardViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + + DashboardScreenContent( + uiState = uiState, + refreshSignal = viewModel.refreshSignal, + snackbarMessageFlow = viewModel.snackbarMessage, + onShowSnackbar = viewModel::showSnackbar, + router = router + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun DashboardScreenContent( + uiState: DashboardUiState, + refreshSignal: SharedFlow, + snackbarMessageFlow: SharedFlow, + onShowSnackbar: (String, String?, (() -> Unit)?) -> Unit, + router: DashboardRouter +) { + val activity = LocalActivity.current + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.refreshing, + onRefresh = uiState.onRefresh + ) + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + snackbarMessageFlow.collect { snackbarMessage -> + val actionLabel = if (snackbarMessage.action != null) snackbarMessage.actionLabel else null + val result = snackbarHostState.showSnackbar( + message = snackbarMessage.message, + actionLabel = actionLabel, + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) { + snackbarMessage.action?.invoke() + } + } + } + + Scaffold( + modifier = Modifier.background(colorResource(R.color.backgroundLightest)), + topBar = { + CanvasThemedAppBar( + title = stringResource(id = R.string.dashboard), + navIconRes = R.drawable.ic_hamburger, + navIconContentDescription = stringResource(id = R.string.navigation_drawer_open), + navigationActionClick = { (activity as? NavigationActivity)?.openNavigationDrawer() } + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + } + ) { paddingValues -> + Box( + modifier = Modifier + .background(colorResource(R.color.backgroundLightest)) + .padding(paddingValues) + .pullRefresh(pullRefreshState) + .fillMaxSize() + ) { + when { + uiState.error != null -> { + ErrorContent( + errorMessage = uiState.error, + retryClick = uiState.onRetry, + modifier = Modifier + .fillMaxSize() + .testTag("errorContent") + ) + } + + uiState.loading -> { + Loading(modifier = Modifier + .fillMaxSize() + .testTag("loading")) + } + + uiState.widgets.isEmpty() -> { + EmptyContent( + emptyMessage = stringResource(id = R.string.noCoursesSubtext), + imageRes = R.drawable.ic_panda_nocourses, + modifier = Modifier + .fillMaxSize() + .testTag("emptyContent") + ) + } + + else -> { + WidgetGrid( + widgets = uiState.widgets, + refreshSignal = refreshSignal, + onShowSnackbar = onShowSnackbar, + router = router, + modifier = Modifier.fillMaxSize() + ) + } + } + + PullRefreshIndicator( + refreshing = uiState.refreshing, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .testTag("dashboardPullRefreshIndicator") + ) + } + } +} + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +private fun WidgetGrid( + widgets: List, + refreshSignal: SharedFlow, + onShowSnackbar: (String, String?, (() -> Unit)?) -> Unit, + router: DashboardRouter, + modifier: Modifier = Modifier +) { + val activity = LocalActivity.current ?: return + val windowSizeClass = calculateWindowSizeClass(activity = activity) + + val columns = when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> 1 + WindowWidthSizeClass.Medium -> 2 + WindowWidthSizeClass.Expanded -> 3 + else -> 1 + } + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(columns), + modifier = modifier, + contentPadding = PaddingValues(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalItemSpacing = 16.dp + ) { + items( + items = widgets, + span = { metadata -> + if (metadata.isFullWidth) { + StaggeredGridItemSpan.FullLine + } else { + StaggeredGridItemSpan.SingleLane + } + } + ) { metadata -> + GetWidgetComposable(metadata.id, refreshSignal, columns, onShowSnackbar, router) + } + } +} + +@Composable +private fun GetWidgetComposable( + widgetId: String, + refreshSignal: SharedFlow, + columns: Int, + onShowSnackbar: (String, String?, (() -> Unit)?) -> Unit, + router: DashboardRouter +) { + return when (widgetId) { + WidgetMetadata.WIDGET_ID_WELCOME -> WelcomeWidget(refreshSignal = refreshSignal) + WidgetMetadata.WIDGET_ID_COURSE_INVITATIONS -> CourseInvitationsWidget( + refreshSignal = refreshSignal, + columns = columns, + onShowSnackbar = onShowSnackbar + ) + WidgetMetadata.WIDGET_ID_INSTITUTIONAL_ANNOUNCEMENTS -> InstitutionalAnnouncementsWidget( + refreshSignal = refreshSignal, + columns = columns, + onAnnouncementClick = router::routeToGlobalAnnouncement + ) + else -> {} + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardUiState.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardUiState.kt new file mode 100644 index 0000000000..9782c8039e --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardUiState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 - 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.dashboard.compose + +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata + +data class DashboardUiState( + val loading: Boolean = true, + val error: String? = null, + val refreshing: Boolean = false, + val widgets: List = emptyList(), + val onRefresh: () -> Unit = {}, + val onRetry: () -> Unit = {} +) \ No newline at end of file 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 new file mode 100644 index 0000000000..fa6edb66cb --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardViewModel.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2024 - 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.dashboard.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.pandautils.compose.SnackbarMessage +import com.instructure.pandautils.features.dashboard.widget.usecase.EnsureDefaultWidgetsUseCase +import com.instructure.pandautils.features.dashboard.widget.usecase.ObserveWidgetMetadataUseCase +import com.instructure.pandautils.utils.NetworkStateProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DashboardViewModel @Inject constructor( + private val networkStateProvider: NetworkStateProvider, + private val ensureDefaultWidgetsUseCase: EnsureDefaultWidgetsUseCase, + private val observeWidgetMetadataUseCase: ObserveWidgetMetadataUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow( + DashboardUiState( + onRefresh = ::onRefresh, + onRetry = ::onRetry + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _refreshSignal = MutableSharedFlow() + val refreshSignal = _refreshSignal.asSharedFlow() + + private val _snackbarMessage = MutableSharedFlow() + val snackbarMessage = _snackbarMessage.asSharedFlow() + + init { + loadDashboard() + } + + fun showSnackbar(message: String, actionLabel: String? = null, action: (() -> Unit)? = null) { + viewModelScope.launch { + _snackbarMessage.emit(SnackbarMessage(message, actionLabel, action)) + } + } + + private fun loadDashboard() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = null) } + try { + launch { ensureDefaultWidgetsUseCase(Unit) } + observeWidgetMetadataUseCase(Unit).collect { widgets -> + val visibleWidgets = widgets.filter { it.isVisible } + _uiState.update { it.copy(loading = false, error = null, widgets = visibleWidgets) } + } + } catch (e: Exception) { + _uiState.update { it.copy(loading = false, error = e.message) } + } + } + } + + private fun onRefresh() { + viewModelScope.launch { + _uiState.update { it.copy(refreshing = true, error = null) } + try { + _refreshSignal.emit(Unit) + _uiState.update { it.copy(refreshing = false, error = null) } + } catch (e: Exception) { + _uiState.update { it.copy(refreshing = false, error = e.message) } + } + } + } + + private fun onRetry() { + loadDashboard() + } +} diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/di/DashboardModule.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/di/DashboardModule.kt new file mode 100644 index 0000000000..ccd9aec59f --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/di/DashboardModule.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 - 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.dashboard.di + +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +class DashboardModule \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt index 3ef8fbc4d9..f735e4312f 100644 --- a/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt +++ b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt @@ -45,7 +45,7 @@ class StudentSettingsBehaviour( if (apiPrefs.canvasForElementary) { preferencesList.add(1, SettingsItem.HOMEROOM_VIEW) } - if (BuildConfig.DEBUG) { + if (BuildConfig.IS_DEBUG) { preferencesList.add(SettingsItem.ACCOUNT_PREFERENCES) preferencesList.add(SettingsItem.FEATURE_FLAGS) preferencesList.add(SettingsItem.REMOTE_CONFIG) diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt b/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt new file mode 100644 index 0000000000..3ec938fa18 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt @@ -0,0 +1,58 @@ +/* + * 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.student.features.todolist + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import com.instructure.pandautils.features.calendar.CalendarFragment +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.calendar.SharedCalendarAction +import com.instructure.pandautils.features.todolist.ToDoListFragment +import com.instructure.pandautils.features.todolist.ToDoListRouter +import com.instructure.student.activity.NavigationActivity +import com.instructure.student.router.RouteMatcher +import org.threeten.bp.LocalDate + +class StudentToDoListRouter( + private val activity: FragmentActivity, + private val fragment: Fragment, + private val calendarSharedEvents: CalendarSharedEvents +) : ToDoListRouter { + + override fun openNavigationDrawer() { + (activity as? NavigationActivity)?.openNavigationDrawer() + } + + override fun attachNavigationDrawer() { + val toDoListFragment = fragment as? ToDoListFragment + if (toDoListFragment != null) { + (activity as? NavigationActivity)?.attachNavigationDrawer(toDoListFragment, null) + } + } + + override fun openToDoItem(htmlUrl: String) { + RouteMatcher.routeUrl(activity, htmlUrl) + } + + override fun openCalendar(date: LocalDate) { + val route = CalendarFragment.makeRoute() + RouteMatcher.route(activity, route) + + calendarSharedEvents.sendEvent(activity.lifecycleScope, SharedCalendarAction.SelectDay(date)) + } +} diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListViewModelBehavior.kt b/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListViewModelBehavior.kt new file mode 100644 index 0000000000..cc95e3ec1e --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListViewModelBehavior.kt @@ -0,0 +1,36 @@ +/* + * 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.student.features.todolist + +import android.appwidget.AppWidgetManager +import android.content.Context +import com.instructure.pandautils.features.todolist.ToDoListViewModelBehavior +import com.instructure.student.widget.WidgetUpdater +import dagger.hilt.android.qualifiers.ApplicationContext + + +class StudentToDoListViewModelBehavior( + @ApplicationContext private val context: Context, + private val widgetUpdater: WidgetUpdater, + private val appWidgetManager: AppWidgetManager +) : ToDoListViewModelBehavior { + + override fun updateWidget(forceRefresh: Boolean) { + context.sendBroadcast(widgetUpdater.getTodoWidgetUpdateIntent(appWidgetManager, forceRefresh = forceRefresh)) + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt similarity index 98% rename from apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt rename to apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt index 392aed5002..2cd28ac3b2 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt @@ -104,7 +104,7 @@ private const val LIST_SPAN_COUNT = 1 @ScreenView(SCREEN_VIEW_DASHBOARD) @PageView @AndroidEntryPoint -class DashboardFragment : ParentFragment() { +class OldDashboardFragment : ParentFragment() { @Inject lateinit var repository: DashboardRepository @@ -261,7 +261,7 @@ class DashboardFragment : ParentFragment() { with (binding) { toolbar.title = title() // Styling done in attachNavigationDrawer - navigation?.attachNavigationDrawer(this@DashboardFragment, toolbar) + navigation?.attachNavigationDrawer(this@OldDashboardFragment, toolbar) recyclerAdapter?.notifyDataSetChanged() } @@ -517,10 +517,10 @@ class DashboardFragment : ParentFragment() { companion object { fun newInstance(route: Route) = - DashboardFragment().apply { + OldDashboardFragment().apply { arguments = route.canvasContext?.makeBundle(route.arguments) ?: route.arguments } - fun makeRoute(canvasContext: CanvasContext?) = Route(DashboardFragment::class.java, canvasContext) + fun makeRoute(canvasContext: CanvasContext?) = Route(OldDashboardFragment::class.java, canvasContext) } } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt similarity index 97% rename from apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt rename to apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt index 983872fe1b..c87f39ba29 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt @@ -59,7 +59,7 @@ import javax.inject.Inject @ScreenView(SCREEN_VIEW_TO_DO_LIST) @PageView @AndroidEntryPoint -class ToDoListFragment : ParentFragment() { +class OldToDoListFragment : ParentFragment() { private val binding by viewBinding(FragmentListTodoBinding::bind) private lateinit var recyclerViewBinding: PandaRecyclerRefreshLayoutBinding @@ -250,13 +250,13 @@ class ToDoListFragment : ParentFragment() { } companion object { - fun makeRoute(canvasContext: CanvasContext): Route = Route(ToDoListFragment::class.java, canvasContext, Bundle()) + fun makeRoute(canvasContext: CanvasContext): Route = Route(OldToDoListFragment::class.java, canvasContext, Bundle()) private fun validateRoute(route: Route) = route.canvasContext != null - fun newInstance(route: Route): ToDoListFragment? { + fun newInstance(route: Route): OldToDoListFragment? { if (!validateRoute(route)) return null - return ToDoListFragment().withArgs(route.canvasContext!!.makeBundle()) + return OldToDoListFragment().withArgs(route.canvasContext!!.makeBundle()) } } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt index 8190b3b1a8..2e647b3187 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt @@ -18,6 +18,7 @@ package com.instructure.student.mobius.assignmentDetails.submissionDetails import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.utils.APIHelper import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.exhaustive @@ -32,7 +33,8 @@ import kotlinx.coroutines.launch import java.io.File class SubmissionDetailsEffectHandler( - private val repository: SubmissionDetailsRepository + private val repository: SubmissionDetailsRepository, + private val apiPrefs: ApiPrefs ) : EffectHandler() { override fun accept(effect: SubmissionDetailsEffect) { @@ -70,9 +72,16 @@ class SubmissionDetailsEffectHandler( // If the user is an observer, get the id of the first observee that comes back, otherwise use the user's id val enrollments = repository.getObserveeEnrollments(true).dataOrNull.orEmpty() val observeeId = enrollments.firstOrNull { it.isObserver && it.courseId == effect.courseId }?.associatedUserId - val userId = observeeId ?: ApiPrefs.user!!.id + val userId = observeeId ?: apiPrefs.user!!.id - val submissionResult = repository.getSingleSubmission(effect.courseId, effect.assignmentId, userId, true) + val finalUserId = APIHelper.getUserIdForCourse( + effect.courseId, + userId, + apiPrefs.shardIds, + apiPrefs.accessToken + ) + + val submissionResult = repository.getSingleSubmission(effect.courseId, effect.assignmentId, finalUserId, true) val assignmentResult = repository.getAssignment(effect.assignmentId, effect.courseId, true) val studioLTIToolResult = if (repository.isOnline() && assignmentResult.containsSubmissionType(Assignment.SubmissionType.ONLINE_UPLOAD)) { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsUpdate.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsUpdate.kt index 5991543769..aca5acd59e 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsUpdate.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsUpdate.kt @@ -22,6 +22,7 @@ import android.webkit.MimeTypeMap import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Attachment import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.LTITool import com.instructure.canvasapi2.models.LtiType import com.instructure.canvasapi2.models.Quiz @@ -29,6 +30,7 @@ import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.validOrNull import com.instructure.pandautils.utils.AssignmentUtils2 +import com.instructure.pandautils.utils.isAllowedToSubmitWithOverrides import com.instructure.student.mobius.common.ui.UpdateInit import com.instructure.student.util.Const import com.spotify.mobius.First @@ -171,7 +173,8 @@ class SubmissionDetailsUpdate : UpdateInit SubmissionDetailsContentType.NoneContent Assignment.SubmissionType.ON_PAPER.apiString in assignment?.submissionTypesRaw.orEmpty() -> SubmissionDetailsContentType.OnPaperContent Assignment.SubmissionType.EXTERNAL_TOOL.apiString in assignment?.submissionTypesRaw.orEmpty() -> { - if (assignment != null && (assignment.isAllowedToSubmit || submission?.workflowState != "unsubmitted")) + val course = canvasContext as? Course + if (assignment != null && (assignment.isAllowedToSubmitWithOverrides(course) || submission?.workflowState != "unsubmitted")) SubmissionDetailsContentType.ExternalToolContent(canvasContext, ltiTool, assignment.name.orEmpty(), assignment.ltiToolType()) else SubmissionDetailsContentType.LockedContent } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/DiscussionSubmissionViewFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/DiscussionSubmissionViewFragment.kt index c34db61c70..8952eba7f9 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/DiscussionSubmissionViewFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/DiscussionSubmissionViewFragment.kt @@ -22,12 +22,11 @@ import android.view.View import android.view.ViewGroup import android.webkit.WebChromeClient import android.webkit.WebView -import com.instructure.pandautils.base.BaseCanvasFragment import com.instructure.canvasapi2.managers.OAuthManager -import com.instructure.canvasapi2.models.AuthenticatedSession import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.weave.StatusCallbackError import com.instructure.canvasapi2.utils.weave.awaitApi +import com.instructure.pandautils.base.BaseCanvasFragment import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.StringArg import com.instructure.pandautils.utils.enableAlgorithmicDarkening @@ -50,6 +49,29 @@ class DiscussionSubmissionViewFragment : BaseCanvasFragment() { private var discussionUrl: String by StringArg() private var authJob: Job? = null + /** + * Check if a URL belongs to any of the valid Canvas domains (main domain or override domains) + */ + private fun isValidCanvasDomain(url: String): Boolean { + if (url.contains(ApiPrefs.domain)) return true + return ApiPrefs.overrideDomains.values.any { overrideDomain -> + !overrideDomain.isNullOrEmpty() && url.contains(overrideDomain) + } + } + + /** + * Get the appropriate domain for a given URL (main domain or matching override domain) + */ + private fun getDomainForUrl(url: String): String { + if (url.contains(ApiPrefs.domain)) return ApiPrefs.domain + ApiPrefs.overrideDomains.values.forEach { overrideDomain -> + if (!overrideDomain.isNullOrEmpty() && url.contains(overrideDomain)) { + return overrideDomain + } + } + return ApiPrefs.domain + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -86,12 +108,12 @@ class DiscussionSubmissionViewFragment : BaseCanvasFragment() { (url != discussionUrl && !url.contains("root_discussion_topic_id")) && RouteMatcher.canRouteInternally( requireActivity(), url, - ApiPrefs.domain, + getDomainForUrl(url), false ) override fun routeInternallyCallback(url: String) { - RouteMatcher.canRouteInternally(requireActivity(), url, ApiPrefs.domain, true) + RouteMatcher.canRouteInternally(requireActivity(), url, getDomainForUrl(url), true) } } @@ -103,18 +125,19 @@ class DiscussionSubmissionViewFragment : BaseCanvasFragment() { ) override fun shouldLaunchInternalWebViewFragment(url: String): Boolean = - !url.contains(ApiPrefs.domain) + !isValidCanvasDomain(url) } binding.discussionSubmissionWebView.setInitialScale(100) authJob = GlobalScope.launch(Dispatchers.Main) { - val authenticatedUrl = if (ApiPrefs.domain in discussionUrl) + val authenticatedUrl = if (isValidCanvasDomain(discussionUrl)) try { - awaitApi { + awaitApi { OAuthManager.getAuthenticatedSession( discussionUrl, - it + it, + getDomainForUrl(discussionUrl).takeIf { it != ApiPrefs.domain } ) }.sessionUrl } catch (e: StatusCallbackError) { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/emptySubmission/SubmissionDetailsEmptyContentPresenter.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/emptySubmission/SubmissionDetailsEmptyContentPresenter.kt index ae75bf5dbb..c62233b5b2 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/emptySubmission/SubmissionDetailsEmptyContentPresenter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/content/emptySubmission/SubmissionDetailsEmptyContentPresenter.kt @@ -19,6 +19,7 @@ import android.content.Context import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.pandautils.utils.getShortMonthAndDay +import com.instructure.pandautils.utils.isAllowedToSubmitWithOverrides import com.instructure.pandautils.utils.getTime import com.instructure.student.R import com.instructure.student.mobius.assignmentDetails.submissionDetails.content.emptySubmission.ui.SubmissionDetailsEmptyContentViewState @@ -76,10 +77,9 @@ object SubmissionDetailsEmptyContentPresenter : Presenter 0) 1 else 0) }) showCompleteNotification( @@ -244,6 +257,14 @@ class SubmissionWorker @AssistedInject constructor( submission, mediaSubmissionResult.dataOrThrow.late ) + + coroutineScope { + calendarSharedEvents.sendEvent( + this, + SharedCalendarAction.RefreshToDoList + ) + } + Result.success() } ?: run { createSubmissionDao.setSubmissionError(true, submission.id) @@ -252,6 +273,7 @@ class SubmissionWorker @AssistedInject constructor( putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) submission.mediaType?.let { putString(AnalyticsParamConstants.MEDIA_TYPE, it) } submission.mediaSource?.let { putString(AnalyticsParamConstants.MEDIA_SOURCE, it) } + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) }) Result.failure() } @@ -261,6 +283,7 @@ class SubmissionWorker @AssistedInject constructor( putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) submission.mediaType?.let { putString(AnalyticsParamConstants.MEDIA_TYPE, it) } submission.mediaSource?.let { putString(AnalyticsParamConstants.MEDIA_SOURCE, it) } + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) }) return Result.failure() } @@ -270,12 +293,14 @@ class SubmissionWorker @AssistedInject constructor( private suspend fun uploadFileSubmission(submission: CreateSubmissionEntity): Result { showProgressNotification(submission.assignmentName, submission.id) + createSubmissionDao.updateSubmissionState(submission.id, SubmissionState.UPLOADING_FILES) + val (completed, pending) = createFileSubmissionDao .findFilesForSubmissionId(submission.id).partition { it.attachmentId != null } val uploadedAttachmentIds = uploadFiles(submission, completed.size, pending) ?: return Result.failure() // Cancel submitting if any of the files failed to upload - // Update the notification to show that we're doing the actual submission now + createSubmissionDao.updateSubmissionState(submission.id, SubmissionState.SUBMITTING) showProgressNotification(submission.assignmentName, submission.id, alertOnlyOnce = true) val attachmentIds = completed.mapNotNull { it.attachmentId } + uploadedAttachmentIds @@ -295,6 +320,14 @@ class SubmissionWorker @AssistedInject constructor( return result.dataOrNull?.let { deleteSubmissionsForAssignment(submission.assignmentId) showCompleteNotification(context, submission, result.dataOrThrow.late) + + coroutineScope { + calendarSharedEvents.sendEvent( + this, + SharedCalendarAction.RefreshToDoList + ) + } + Result.success() } ?: run { createSubmissionDao.setSubmissionError(true, submission.id) @@ -357,6 +390,7 @@ class SubmissionWorker @AssistedInject constructor( }).onSuccess { attachment -> analytics.logEvent(AnalyticsEventConstants.SUBMIT_FILEUPLOAD_SUCCEEDED, Bundle().apply { putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) }) updateFileProgress( submission.id, @@ -379,6 +413,7 @@ class SubmissionWorker @AssistedInject constructor( }.onFailure { analytics.logEvent(AnalyticsEventConstants.SUBMIT_FILEUPLOAD_FAILED, Bundle().apply { putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) }) runBlocking { handleFileError(submission, index, attachments, it?.message) @@ -630,6 +665,8 @@ class SubmissionWorker @AssistedInject constructor( submission: CreateSubmissionEntity ): Result { return result.dataOrNull?.let { + createSubmissionDao.updateSubmissionState(submission.id, SubmissionState.COMPLETED) + createSubmissionDao.setCanvasSubmissionId(submission.id, result.dataOrThrow.id) createSubmissionDao.deleteSubmissionById(submission.id) if (!result.dataOrThrow.late) showConfetti() @@ -637,54 +674,90 @@ class SubmissionWorker @AssistedInject constructor( Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString -> { analytics.logEvent(AnalyticsEventConstants.SUBMIT_TEXTENTRY_SUCCEEDED, Bundle().apply { putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) }) } Assignment.SubmissionType.ONLINE_URL.apiString -> { analytics.logEvent(AnalyticsEventConstants.SUBMIT_URL_SUCCEEDED, Bundle().apply { putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) }) } Assignment.SubmissionType.BASIC_LTI_LAUNCH.apiString -> { analytics.logEvent(AnalyticsEventConstants.SUBMIT_STUDIO_SUCCEEDED, Bundle().apply { putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) }) } Assignment.SubmissionType.STUDENT_ANNOTATION.apiString -> { analytics.logEvent(AnalyticsEventConstants.SUBMIT_ANNOTATION_SUCCEEDED, Bundle().apply { putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) }) } } + coroutineScope { + calendarSharedEvents.sendEvent( + this, + SharedCalendarAction.RefreshToDoList + ) + } + Result.success() } ?: run { - createSubmissionDao.setSubmissionError(true, submission.id) - showErrorNotification(context, submission) + val failure = (result as DataResult.Fail).failure + val errorMessage = failure?.message ?: "Unknown error" - when (submission.submissionType) { - Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString -> { - analytics.logEvent(AnalyticsEventConstants.SUBMIT_TEXTENTRY_FAILED, Bundle().apply { - putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) - }) - } - Assignment.SubmissionType.ONLINE_URL.apiString -> { - analytics.logEvent(AnalyticsEventConstants.SUBMIT_URL_FAILED, Bundle().apply { - putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) - }) - } - Assignment.SubmissionType.BASIC_LTI_LAUNCH.apiString -> { - analytics.logEvent(AnalyticsEventConstants.SUBMIT_STUDIO_FAILED, Bundle().apply { - putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) - }) - } - Assignment.SubmissionType.STUDENT_ANNOTATION.apiString -> { - analytics.logEvent(AnalyticsEventConstants.SUBMIT_ANNOTATION_FAILED, Bundle().apply { - putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) - }) + val shouldRetry = when (failure) { + is Failure.Network -> { + val errorCode = failure.errorCode + errorCode == null || errorCode >= 500 } + is Failure.Exception -> true + is Failure.Authorization -> false + else -> false } - Result.failure() + if (shouldRetry && submission.retryCount < 3) { + createSubmissionDao.updateSubmissionState(submission.id, SubmissionState.RETRYING) + createSubmissionDao.incrementRetryCount(submission.id, errorMessage) + return Result.retry() + } else { + createSubmissionDao.updateSubmissionState(submission.id, SubmissionState.FAILED) + createSubmissionDao.setSubmissionError(true, submission.id) + createSubmissionDao.incrementRetryCount(submission.id, errorMessage) + showErrorNotification(context, submission) + + when (submission.submissionType) { + Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString -> { + analytics.logEvent(AnalyticsEventConstants.SUBMIT_TEXTENTRY_FAILED, Bundle().apply { + putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) + }) + } + Assignment.SubmissionType.ONLINE_URL.apiString -> { + analytics.logEvent(AnalyticsEventConstants.SUBMIT_URL_FAILED, Bundle().apply { + putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) + }) + } + Assignment.SubmissionType.BASIC_LTI_LAUNCH.apiString -> { + analytics.logEvent(AnalyticsEventConstants.SUBMIT_STUDIO_FAILED, Bundle().apply { + putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) + }) + } + Assignment.SubmissionType.STUDENT_ANNOTATION.apiString -> { + analytics.logEvent(AnalyticsEventConstants.SUBMIT_ANNOTATION_FAILED, Bundle().apply { + putString(AnalyticsParamConstants.ATTEMPT, submission.attempt.toString()) + putInt(AnalyticsParamConstants.RETRY, if (submission.retryCount > 0) 1 else 0) + }) + } + } + + Result.failure() + } } } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListModels.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListModels.kt index 465b9c8ca2..f229897137 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListModels.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListModels.kt @@ -26,6 +26,12 @@ sealed class ConferenceListEvent { object LaunchInBrowserFinished : ConferenceListEvent() data class DataLoaded(val listResult: DataResult>) : ConferenceListEvent() data class ConferenceClicked(val conferenceId: Long) : ConferenceListEvent() + data class HeaderClicked(val headerType: ConferenceHeaderType) : ConferenceListEvent() +} + +enum class ConferenceHeaderType { + NEW_CONFERENCES, + CONCLUDED_CONFERENCES } sealed class ConferenceListEffect { @@ -38,5 +44,7 @@ data class ConferenceListModel( val canvasContext: CanvasContext, val isLoading: Boolean = false, val isLaunchingInBrowser: Boolean = false, - val listResult: DataResult>? = null + val listResult: DataResult>? = null, + val isNewConferencesExpanded: Boolean = true, + val isConcludedConferencesExpanded: Boolean = true ) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListPresenter.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListPresenter.kt index 6dff778f60..412caba6d7 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListPresenter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ConferenceListPresenter.kt @@ -58,26 +58,38 @@ object ConferenceListPresenter : Presenter Next.next(model.copy(isLaunchingInBrowser = false)) + is ConferenceListEvent.HeaderClicked -> { + when (event.headerType) { + ConferenceHeaderType.NEW_CONFERENCES -> { + Next.next(model.copy(isNewConferencesExpanded = !model.isNewConferencesExpanded)) + } + ConferenceHeaderType.CONCLUDED_CONFERENCES -> { + Next.next(model.copy(isConcludedConferencesExpanded = !model.isConcludedConferencesExpanded)) + } + } + } } } } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListAdapter.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListAdapter.kt index a1d8705822..047a63f1cd 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListAdapter.kt @@ -26,10 +26,12 @@ import com.instructure.student.R import com.instructure.student.databinding.AdapterConferenceHeaderBinding import com.instructure.student.databinding.AdapterConferenceItemBinding import com.instructure.student.databinding.AdapterConferenceListErrorBinding +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType interface ConferenceListAdapterCallback : BasicItemCallback { fun onConferenceClicked(conferenceId: Long) fun reload() + fun onHeaderClicked(headerType: ConferenceHeaderType) } class ConferenceListAdapter(callback: ConferenceListAdapterCallback) : @@ -59,9 +61,18 @@ class ConferenceListErrorBinder : BasicItemBinder() { override val layoutResId = R.layout.adapter_conference_header - override val bindBehavior = Item {data, _, _ -> + override val bindBehavior = Item {data, callback, _ -> val binding = AdapterConferenceHeaderBinding.bind(this) binding.title.text = data.title + + binding.expandIcon.animate() + .rotation(if (data.isExpanded) 180f else 0f) + .setDuration(200) + .start() + + binding.headerContainer.onClick { + callback.onHeaderClicked(data.headerType) + } } } 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 5e76e7b87a..7bb9d6daf8 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 @@ -39,6 +39,7 @@ import com.instructure.student.R import com.instructure.student.databinding.FragmentConferenceListBinding import com.instructure.student.mobius.common.ui.MobiusView import com.instructure.student.mobius.conferences.conference_details.ui.ConferenceDetailsRepositoryFragment +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType import com.instructure.student.mobius.conferences.conference_list.ConferenceListEvent import com.instructure.student.router.RouteMatcher import com.spotify.mobius.functions.Consumer @@ -84,6 +85,10 @@ class ConferenceListView( } override fun reload() = output.accept(ConferenceListEvent.PullToRefresh) + + override fun onHeaderClicked(headerType: ConferenceHeaderType) { + output.accept(ConferenceListEvent.HeaderClicked(headerType)) + } }) binding.recyclerView.layoutManager = LinearLayoutManager(context) binding.recyclerView.adapter = listAdapter diff --git a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListViewState.kt b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListViewState.kt index d112af04a3..445484eb4a 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListViewState.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/conferences/conference_list/ui/ConferenceListViewState.kt @@ -16,6 +16,8 @@ */ package com.instructure.student.mobius.conferences.conference_list.ui +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType + sealed class ConferenceListViewState(open val isLaunchingInBrowser: Boolean) { class Loading(isLaunchingInBrowser: Boolean) : ConferenceListViewState(isLaunchingInBrowser) data class Loaded( @@ -27,7 +29,11 @@ sealed class ConferenceListViewState(open val isLaunchingInBrowser: Boolean) { sealed class ConferenceListItemViewState { object Empty : ConferenceListItemViewState() object Error : ConferenceListItemViewState() - data class ConferenceHeader(val title: String): ConferenceListItemViewState() + data class ConferenceHeader( + val title: String, + val headerType: ConferenceHeaderType, + val isExpanded: Boolean + ): ConferenceListItemViewState() data class ConferenceItem( val tint: Int, val title: String, diff --git a/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt index 124ee3612c..d67a64675a 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt @@ -19,26 +19,37 @@ package com.instructure.student.navigation import androidx.fragment.app.Fragment import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.interactions.router.Route import com.instructure.pandautils.features.calendar.CalendarFragment import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.R -import com.instructure.student.fragment.DashboardFragment +import com.instructure.student.features.dashboard.compose.DashboardFragment +import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.NotificationListFragment import com.instructure.student.fragment.ParentFragment -import com.instructure.student.fragment.ToDoListFragment -class DefaultNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationBehavior { +class DefaultNavigationBehavior(apiPrefs: ApiPrefs) : NavigationBehavior { + + private val dashboardFragmentClass: Class + get() { + return if (RemoteConfigUtils.getBoolean(RemoteConfigParam.DASHBOARD_REDESIGN)) { + DashboardFragment::class.java + } else { + OldDashboardFragment::class.java + } + } override val bottomNavBarFragments: List> = listOf( - DashboardFragment::class.java, + dashboardFragmentClass, CalendarFragment::class.java, - ToDoListFragment::class.java, + todoFragmentClass, NotificationListFragment::class.java, getInboxBottomBarFragment(apiPrefs) ) - override val homeFragmentClass: Class = DashboardFragment::class.java + override val homeFragmentClass: Class = dashboardFragmentClass override val visibleNavigationMenuItems: Set = setOf(NavigationMenuItem.FILES, NavigationMenuItem.BOOKMARKS, NavigationMenuItem.SETTINGS) @@ -52,10 +63,18 @@ class DefaultNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationBeha override val bottomBarMenu: Int = R.menu.bottom_bar_menu override fun createHomeFragmentRoute(canvasContext: CanvasContext?): Route { - return DashboardFragment.makeRoute(ApiPrefs.user) + return if (RemoteConfigUtils.getBoolean(RemoteConfigParam.DASHBOARD_REDESIGN)) { + DashboardFragment.makeRoute(ApiPrefs.user) + } else { + OldDashboardFragment.makeRoute(ApiPrefs.user) + } } override fun createHomeFragment(route: Route): ParentFragment { - return DashboardFragment.newInstance(route) + return if (RemoteConfigUtils.getBoolean(RemoteConfigParam.DASHBOARD_REDESIGN)) { + DashboardFragment.newInstance(route) + } else { + OldDashboardFragment.newInstance(route) + } } } \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt index 9492060f65..6f2e276459 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt @@ -25,7 +25,6 @@ import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.R import com.instructure.student.fragment.NotificationListFragment import com.instructure.student.fragment.ParentFragment -import com.instructure.student.fragment.ToDoListFragment import com.instructure.student.mobius.elementary.ElementaryDashboardFragment class ElementaryNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationBehavior { @@ -33,7 +32,7 @@ class ElementaryNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationB override val bottomNavBarFragments: List> = listOf( ElementaryDashboardFragment::class.java, CalendarFragment::class.java, - ToDoListFragment::class.java, + todoFragmentClass, NotificationListFragment::class.java, getInboxBottomBarFragment(apiPrefs) ) diff --git a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt index 7ac1999403..533c791c8d 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt @@ -20,10 +20,14 @@ import androidx.annotation.MenuRes import androidx.fragment.app.Fragment import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.interactions.router.Route import com.instructure.pandautils.features.inbox.list.InboxFragment import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.activity.NothingToSeeHereFragment +import com.instructure.pandautils.features.todolist.ToDoListFragment +import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.fragment.ParentFragment interface NavigationBehavior { @@ -31,7 +35,7 @@ interface NavigationBehavior { /** 'Root' fragments that should include the bottom nav bar */ val bottomNavBarFragments: List> - val homeFragmentClass: Class + val homeFragmentClass: Class val visibleNavigationMenuItems: Set @@ -41,6 +45,15 @@ interface NavigationBehavior { val canvasFont: CanvasFont + val todoFragmentClass: Class + get() { + return if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { + ToDoListFragment::class.java + } else { + OldToDoListFragment::class.java + } + } + @get:MenuRes val bottomBarMenu: Int diff --git a/apps/student/src/main/java/com/instructure/student/router/EnabledTabs.kt b/apps/student/src/main/java/com/instructure/student/router/EnabledTabs.kt index 401fcf6aa3..facd29da13 100644 --- a/apps/student/src/main/java/com/instructure/student/router/EnabledTabs.kt +++ b/apps/student/src/main/java/com/instructure/student/router/EnabledTabs.kt @@ -8,6 +8,7 @@ import com.instructure.canvasapi2.models.Tab import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.depaginate import com.instructure.interactions.router.Route +import androidx.core.net.toUri interface EnabledTabs { fun isPathTabNotEnabled(route: Route?): Boolean @@ -66,8 +67,22 @@ class EnabledTabsImpl: EnabledTabs { api.next(it, RestParams(usePerPageQueryParam = true)) }.dataOrNull?.associate { it.id to (it.tabs ?: emptyList()) } ?: emptyMap() enabledTabs?.forEach { entry -> - entry.value.find { tab -> tab.tabId == Tab.ASSIGNMENTS_ID }?.domain?.let { domain -> - ApiPrefs.overrideDomains[entry.key] = domain + entry.value.find { tab -> tab.tabId == Tab.ASSIGNMENTS_ID }?.let { tab -> + tab.domain?.let { domain -> + ApiPrefs.overrideDomains[entry.key] = domain + } + + // Parse shard ID from full_url if it contains a tilde + // Example: "https://example.com/courses/7053~2848/assignments" -> "7053" + tab.externalUrl?.let { externalUrl -> + val uri = externalUrl.toUri() + val courseIdIndex = uri.pathSegments.indexOf("courses") + 1 + val courseIdSegment = uri.pathSegments.getOrNull(courseIdIndex) ?: return@let + if (courseIdSegment.contains("~")) { + val shardId = courseIdSegment.substringBefore("~") + ApiPrefs.shardIds[entry.key] = shardId + } + } } } } diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt b/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt index 4b9c97bf8f..ab904846d4 100644 --- a/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt +++ b/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt @@ -20,8 +20,10 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater.from import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat.getColor import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope @@ -33,6 +35,8 @@ import com.instructure.canvasapi2.models.FileFolder import com.instructure.canvasapi2.models.Tab import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.Logger +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.interactions.router.BaseRouteMatcher @@ -52,6 +56,7 @@ import com.instructure.pandautils.features.inbox.details.InboxDetailsFragment import com.instructure.pandautils.features.inbox.list.InboxFragment import com.instructure.pandautils.features.offline.sync.progress.SyncProgressFragment import com.instructure.pandautils.features.shareextension.ShareFileSubmissionTarget +import com.instructure.pandautils.features.todolist.ToDoListFragment import com.instructure.pandautils.loaders.OpenMediaAsyncTaskLoader import com.instructure.pandautils.room.offline.OfflineDatabase import com.instructure.pandautils.utils.Const @@ -61,6 +66,7 @@ import com.instructure.pandautils.utils.RouteUtils import com.instructure.pandautils.utils.nonNullArgs import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.toast +import com.instructure.pandautils.views.CanvasLoadingView import com.instructure.student.R import com.instructure.student.activity.InternalWebViewActivity import com.instructure.student.activity.InterwebsToApplication @@ -83,12 +89,12 @@ import com.instructure.student.features.quiz.list.QuizListFragment import com.instructure.student.fragment.AnnouncementListFragment import com.instructure.student.fragment.BasicQuizViewFragment import com.instructure.student.fragment.CourseSettingsFragment -import com.instructure.student.fragment.DashboardFragment +import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.InternalWebviewFragment import com.instructure.student.fragment.NotificationListFragment +import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.fragment.ProfileSettingsFragment import com.instructure.student.fragment.StudioWebViewFragment -import com.instructure.student.fragment.ToDoListFragment import com.instructure.student.fragment.UnsupportedFeatureFragment import com.instructure.student.fragment.UnsupportedTabFragment import com.instructure.student.fragment.ViewHtmlFragment @@ -119,7 +125,7 @@ object RouteMatcher : BaseRouteMatcher() { // Be sensitive to the order of items. It really, really matters. @androidx.annotation.OptIn(com.google.android.material.badge.ExperimentalBadgeUtils::class) private fun initRoutes() { - routes.add(Route("/", DashboardFragment::class.java)) + routes.add(Route("/", OldDashboardFragment::class.java)) // region Conversations routes.add(Route("/conversations", InboxFragment::class.java)) routes.add(Route("/conversations/:${InboxDetailsFragment.CONVERSATION_ID}", InboxDetailsFragment::class.java)) @@ -129,7 +135,7 @@ object RouteMatcher : BaseRouteMatcher() { ////////////////////////// // Courses ////////////////////////// - routes.add(Route(courseOrGroup("/"), DashboardFragment::class.java)) + routes.add(Route(courseOrGroup("/"), OldDashboardFragment::class.java)) routes.add( Route( courseOrGroup("/:${RouterParams.COURSE_ID}"), @@ -343,7 +349,12 @@ object RouteMatcher : BaseRouteMatcher() { routes.add(Route("/todos/:${ToDoFragment.PLANNABLE_ID}", ToDoFragment::class.java)) // To Do List - routes.add(Route("/todolist", ToDoListFragment::class.java).copy(canvasContext = ApiPrefs.user)) + val todoListFragmentClass = if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { + ToDoListFragment::class.java + } else { + OldToDoListFragment::class.java + } + routes.add(Route("/todolist", todoListFragmentClass).copy(canvasContext = ApiPrefs.user)) // Syllabus routes.add(Route(courseOrGroup("/:${RouterParams.COURSE_ID}/assignments/syllabus"), SyllabusRepositoryFragment::class.java)) @@ -653,10 +664,15 @@ object RouteMatcher : BaseRouteMatcher() { override fun onCreateLoader(id: Int, args: Bundle?): Loader { if (!activity.isFinishing) { - dialog = AlertDialog.Builder(activity, com.instructure.pandautils.R.style.CustomViewAlertDialog) - .setView(com.instructure.pandautils.R.layout.dialog_loading_view) + val view = from(activity).inflate(R.layout.dialog_loading_view, null) + val loadingView = view.findViewById(R.id.canvasLoadingView) + val studentColor = getColor(activity, R.color.login_studentAppTheme) + loadingView?.setOverrideColor(studentColor) + + dialog = AlertDialog.Builder(activity, R.style.CustomViewAlertDialog) + .setView(view) .create() - dialog!!.show() + dialog?.show() } return OpenMediaAsyncTaskLoader(activity, args) } diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt b/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt index 0052ea522e..903087be46 100644 --- a/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt +++ b/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt @@ -30,6 +30,7 @@ import com.instructure.pandautils.utils.Const import com.instructure.student.AnnotationComments.AnnotationCommentListFragment import com.instructure.student.activity.NothingToSeeHereFragment import com.instructure.student.features.coursebrowser.CourseBrowserFragment +import com.instructure.student.features.dashboard.compose.DashboardFragment import com.instructure.student.features.discussion.details.DiscussionDetailsFragment import com.instructure.student.features.discussion.list.DiscussionListFragment import com.instructure.student.features.elementary.course.ElementaryCourseFragment @@ -45,19 +46,20 @@ import com.instructure.student.features.pages.list.PageListFragment import com.instructure.student.features.people.details.PeopleDetailsFragment import com.instructure.student.features.people.list.PeopleListFragment import com.instructure.student.features.quiz.list.QuizListFragment +import com.instructure.pandautils.features.todolist.ToDoListFragment import com.instructure.student.fragment.AccountPreferencesFragment import com.instructure.student.fragment.AnnouncementListFragment import com.instructure.student.fragment.AssignmentBasicFragment import com.instructure.student.fragment.BasicQuizViewFragment import com.instructure.student.fragment.CourseSettingsFragment -import com.instructure.student.fragment.DashboardFragment +import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.EditPageDetailsFragment import com.instructure.student.fragment.FeatureFlagsFragment import com.instructure.student.fragment.InternalWebviewFragment import com.instructure.student.fragment.NotificationListFragment +import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.fragment.ProfileSettingsFragment import com.instructure.student.fragment.StudioWebViewFragment -import com.instructure.student.fragment.ToDoListFragment import com.instructure.student.fragment.UnknownItemFragment import com.instructure.student.fragment.UnsupportedFeatureFragment import com.instructure.student.fragment.UnsupportedTabFragment @@ -112,8 +114,10 @@ object RouteResolver { // Divided up into two camps, those who need a valid CanvasContext and those who do not return when { + cls.isA() -> OldDashboardFragment.newInstance(route) cls.isA() -> DashboardFragment.newInstance(route) cls.isA() -> ElementaryDashboardFragment.newInstance(route) + cls.isA() -> OldToDoListFragment.newInstance(route) cls.isA() -> ToDoListFragment.newInstance(route) cls.isA() -> NotificationListFragment.newInstance(route) cls.isA() -> InboxFragment.newInstance(route) diff --git a/apps/student/src/main/java/com/instructure/student/widget/WidgetUpdater.kt b/apps/student/src/main/java/com/instructure/student/widget/WidgetUpdater.kt index 6bcf23dda5..335e5c14be 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/WidgetUpdater.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/WidgetUpdater.kt @@ -79,11 +79,12 @@ object WidgetUpdater { return intent } - fun getTodoWidgetUpdateIntent(appWidgetManager: AppWidgetManager): Intent { + fun getTodoWidgetUpdateIntent(appWidgetManager: AppWidgetManager, forceRefresh: Boolean = true): Intent { val intent = Intent(ContextKeeper.appContext, ToDoWidgetReceiver::class.java) intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(ContextKeeper.appContext, ToDoWidgetReceiver::class.java)) intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds) + intent.putExtra(ToDoWidgetReceiver.EXTRA_FORCE_REFRESH, forceRefresh) return intent } } diff --git a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetReceiver.kt b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetReceiver.kt index 462a1ee4ee..4a69fb2f21 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetReceiver.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetReceiver.kt @@ -19,6 +19,7 @@ package com.instructure.student.widget.todo import android.appwidget.AppWidgetManager import android.content.Context +import android.content.Intent import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetReceiver @@ -64,14 +65,18 @@ class ToDoWidgetReceiver : GlanceAppWidgetReceiver() { super.onDeleted(context, appWidgetIds) } - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - updateData(context) + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + // Instead of handling the update in onUpdate, we handle it here so that we can also get intent extras + if (intent.action == AppWidgetManager.ACTION_APPWIDGET_UPDATE || intent.action == AppWidgetManager.ACTION_APPWIDGET_RESTORED) { + val forceRefresh = intent.getBooleanExtra(EXTRA_FORCE_REFRESH, true) + updateData(context, forceRefresh) + } } - private fun updateData(context: Context) { + private fun updateData(context: Context, forceNetwork: Boolean = true) { coroutineScope.launch { - toDoWidgetUpdater.updateData(context).collectLatest { + toDoWidgetUpdater.updateData(context, forceNetwork).collectLatest { val glanceId = GlanceAppWidgetManager(context) .getGlanceIds(ToDoWidget::class.java) .firstOrNull() ?: return@collectLatest @@ -89,5 +94,6 @@ class ToDoWidgetReceiver : GlanceAppWidgetReceiver() { companion object { val toDoWidgetUiStateKey = stringPreferencesKey("toDoWidgetUiState") + const val EXTRA_FORCE_REFRESH = "extra_force_refresh" } } diff --git a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetRepository.kt b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetRepository.kt index 107090320a..7804987fb6 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetRepository.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetRepository.kt @@ -23,20 +23,22 @@ import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.PlannableType import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.depaginate -import com.instructure.pandautils.room.calendar.daos.CalendarFilterDao -import com.instructure.pandautils.room.calendar.entities.CalendarFilterEntity +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity +import com.instructure.pandautils.utils.orDefault class ToDoWidgetRepository( private val plannerApi: PlannerAPI.PlannerInterface, private val coursesApi: CourseAPI.CoursesInterface, - private val calendarFilterDao: CalendarFilterDao + private val toDoFilterDao: ToDoFilterDao, + private val apiPrefs: ApiPrefs ) { suspend fun getPlannerItems( startDate: String, endDate: String, - contextCodes: List, forceNetwork: Boolean ): DataResult> { val restParams = RestParams( @@ -48,7 +50,7 @@ class ToDoWidgetRepository( return plannerApi.getPlannerItems( startDate, endDate, - contextCodes, + emptyList(), null, restParams ).depaginate { @@ -72,7 +74,10 @@ class ToDoWidgetRepository( }.dataOrNull.orEmpty() } - suspend fun getCalendarFilters(userId: Long, domain: String): CalendarFilterEntity? { - return calendarFilterDao.findByUserIdAndDomain(userId, domain) + suspend fun getToDoFilters(): ToDoFilterEntity { + return toDoFilterDao.findByUser( + apiPrefs.fullDomain, + apiPrefs.user?.id.orDefault() + ) ?: ToDoFilterEntity(userDomain = apiPrefs.fullDomain, userId = apiPrefs.user?.id.orDefault()) } } diff --git a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetUpdater.kt b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetUpdater.kt index 272cd376f3..92a21b8fa6 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetUpdater.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetUpdater.kt @@ -19,24 +19,23 @@ package com.instructure.student.widget.todo import android.content.Context import com.instructure.canvasapi2.models.Course -import com.instructure.canvasapi2.models.PlannableType import com.instructure.canvasapi2.models.PlannerItem import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult -import com.instructure.canvasapi2.utils.DateHelper import com.instructure.canvasapi2.utils.Failure import com.instructure.canvasapi2.utils.toApiString -import com.instructure.canvasapi2.utils.toDate import com.instructure.pandautils.utils.courseOrUserColor +import com.instructure.pandautils.utils.filterByToDoFilters +import com.instructure.pandautils.utils.getContextNameForPlannerItem +import com.instructure.pandautils.utils.getDateTextForPlannerItem import com.instructure.pandautils.utils.getIconForPlannerItem import com.instructure.pandautils.utils.getTagForPlannerItem -import com.instructure.pandautils.utils.orDefault +import com.instructure.pandautils.utils.getUrl +import com.instructure.pandautils.utils.isComplete import com.instructure.pandautils.utils.toLocalDate -import com.instructure.student.R import com.instructure.student.widget.glance.WidgetState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -import org.threeten.bp.LocalDate private const val PLANNER_DATE_RANGE_DAYS = 28L @@ -45,7 +44,7 @@ class ToDoWidgetUpdater( private val repository: ToDoWidgetRepository, private val apiPrefs: ApiPrefs ) { - suspend fun updateData(context: Context): Flow { + fun updateData(context: Context, forceNetwork: Boolean = true): Flow { return flow { emit(ToDoWidgetUiState(WidgetState.Loading)) @@ -56,16 +55,13 @@ class ToDoWidgetUpdater( } try { - val courses = repository.getCourses(true) - val calendarFilters = repository.getCalendarFilters(apiPrefs.user?.id.orDefault(), apiPrefs.fullDomain) + val courses = repository.getCourses(forceNetwork) + val todoFilters = repository.getToDoFilters() - val now = LocalDate.now().atStartOfDay() - val plannerItemsDataResult = repository.getPlannerItems( - now.minusDays(PLANNER_DATE_RANGE_DAYS).toApiString().orEmpty(), - now.plusDays(PLANNER_DATE_RANGE_DAYS).toApiString().orEmpty(), - calendarFilters?.filters.orEmpty().toList(), - true - ) + val startDate = todoFilters.pastDateRange.calculatePastDateRange().toApiString() + val endDate = todoFilters.futureDateRange.calculateFutureDateRange().toApiString() + + val plannerItemsDataResult = repository.getPlannerItems(startDate, endDate, forceNetwork) if (plannerItemsDataResult is DataResult.Fail && plannerItemsDataResult.failure is Failure.Authorization) { emit(ToDoWidgetUiState(WidgetState.NotLoggedIn)) @@ -73,8 +69,9 @@ class ToDoWidgetUpdater( } // Other errors are handled in catch val plannerItems = plannerItemsDataResult.dataOrThrow - .filter { it.plannerOverride?.markedComplete != true } - .filter { !isComplete(it) } + .filterByToDoFilters(todoFilters, courses) + .filter { !it.isComplete() } + .sortedBy { it.comparisonDate } val toDoWidgetUiState = ToDoWidgetUiState( if (plannerItems.isEmpty()) { @@ -94,17 +91,6 @@ class ToDoWidgetUpdater( } } - private fun isComplete(plannerItem: PlannerItem): Boolean { - return if (plannerItem.plannableType == PlannableType.ASSIGNMENT - || plannerItem.plannableType == PlannableType.DISCUSSION_TOPIC - || plannerItem.plannableType == PlannableType.SUB_ASSIGNMENT - ) { - plannerItem.submissionState?.submitted == true - } else { - false - } - } - private fun PlannerItem.toWidgetPlannerItem( context: Context, courses: List @@ -115,82 +101,7 @@ class ToDoWidgetUpdater( canvasContextText = getContextNameForPlannerItem(context, courses), title = plannable.title, dateText = getDateTextForPlannerItem(context).orEmpty(), - url = getUrl(), + url = getUrl(apiPrefs), tag = getTagForPlannerItem(context) ) - - private fun PlannerItem.getContextNameForPlannerItem(context: Context, courses: List): String { - val courseCode = courses.find { it.id == canvasContext.id }?.courseCode - return when (plannableType) { - PlannableType.PLANNER_NOTE -> { - if (contextName.isNullOrEmpty()) { - context.getString(R.string.userCalendarToDo) - } else { - context.getString(R.string.courseToDo, courseCode) - } - } - - else -> { - if (canvasContext is Course) { - courseCode.orEmpty() - } else { - contextName.orEmpty() - } - } - } - } - - private fun PlannerItem.getDateTextForPlannerItem(context: Context): String? { - return when (plannableType) { - PlannableType.PLANNER_NOTE -> { - plannable.todoDate.toDate()?.let { - DateHelper.getFormattedTime(context, it) - } - } - - PlannableType.CALENDAR_EVENT -> { - val startDate = plannable.startAt - val endDate = plannable.endAt - if (startDate != null && endDate != null) { - val startText = DateHelper.getFormattedTime(context, startDate).orEmpty() - val endText = DateHelper.getFormattedTime(context, endDate).orEmpty() - - when { - plannable.allDay == true -> context.getString(R.string.widgetAllDay) - startDate == endDate -> startText - else -> context.getString(R.string.widgetFromTo, startText, endText) - } - } else null - } - - else -> { - plannable.dueAt?.let { - val timeText = DateHelper.getFormattedTime(context, it).orEmpty() - context.getString(R.string.widgetDueDate, timeText) - } - } - } - } - - private fun PlannerItem.getUrl(): String { - val url = when (plannableType) { - PlannableType.CALENDAR_EVENT -> { - "/${canvasContext.type.apiString}/${canvasContext.id}/calendar_events/${plannable.id}" - } - - PlannableType.PLANNER_NOTE -> { - "/todos/${plannable.id}" - } - - else -> { - htmlUrl.orEmpty() - } - } - - return if (url.startsWith("/")) { - apiPrefs.fullDomain + url - } else { - url - } - } } diff --git a/apps/student/src/main/res/layout/adapter_conference_header.xml b/apps/student/src/main/res/layout/adapter_conference_header.xml index 1b3e831814..79ae40eb94 100644 --- a/apps/student/src/main/res/layout/adapter_conference_header.xml +++ b/apps/student/src/main/res/layout/adapter_conference_header.xml @@ -13,24 +13,38 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + android:paddingTop="16dp" + android:paddingBottom="8dp" + android:gravity="center_vertical" + android:background="?attr/selectableItemBackground"> - + + + diff --git a/apps/student/src/test/java/com/instructure/student/features/assignments/details/StudentAssignmentDetailsSubmissionHandlerTest.kt b/apps/student/src/test/java/com/instructure/student/features/assignments/details/StudentAssignmentDetailsSubmissionHandlerTest.kt index aca2302814..ec33d45eb5 100644 --- a/apps/student/src/test/java/com/instructure/student/features/assignments/details/StudentAssignmentDetailsSubmissionHandlerTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/assignments/details/StudentAssignmentDetailsSubmissionHandlerTest.kt @@ -69,8 +69,10 @@ class StudentAssignmentDetailsSubmissionHandlerTest { fun `Test initial values`() { submissionHandler = StudentAssignmentDetailsSubmissionHandler(submissionHelper, studentDb) assertEquals(false, submissionHandler.isUploading) + assertEquals(false, submissionHandler.isFailed) assertEquals(false, submissionHandler.lastSubmissionIsDraft) assertEquals(null, submissionHandler.lastSubmissionEntry) + assertEquals(null, submissionHandler.lastSubmissionId) assertEquals(null, submissionHandler.lastSubmissionAssignmentId) assertEquals(null, submissionHandler.lastSubmissionSubmissionType) } @@ -97,7 +99,7 @@ class StudentAssignmentDetailsSubmissionHandlerTest { studentDb.submissionDao().findSubmissionsByAssignmentIdLiveData(any(), any()) } returns liveData - submissionHandler.addAssignmentSubmissionObserver(mockk(relaxed = true), 0, 0, mockk(relaxed = true), data, {}) + submissionHandler.addAssignmentSubmissionObserver(mockk(relaxed = true), 0, 0, mockk(relaxed = true), data, {}, {}) liveData.postValue(listOf(getDbSubmission())) @@ -130,7 +132,7 @@ class StudentAssignmentDetailsSubmissionHandlerTest { } returns liveData submissionHandler.addAssignmentSubmissionObserver( - mockk(relaxed = true), 0, 0, mockk(relaxed = true), data, {}) + mockk(relaxed = true), 0, 0, mockk(relaxed = true), data, {}, {}) liveData.postValue(listOf(getDbSubmission())) assertTrue(submissionHandler.isUploading) diff --git a/apps/student/src/test/java/com/instructure/student/features/dashboard/compose/DashboardViewModelTest.kt b/apps/student/src/test/java/com/instructure/student/features/dashboard/compose/DashboardViewModelTest.kt new file mode 100644 index 0000000000..83d06338eb --- /dev/null +++ b/apps/student/src/test/java/com/instructure/student/features/dashboard/compose/DashboardViewModelTest.kt @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2024 - 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.dashboard.compose + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.instructure.pandautils.compose.SnackbarMessage +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.usecase.EnsureDefaultWidgetsUseCase +import com.instructure.pandautils.features.dashboard.widget.usecase.ObserveWidgetMetadataUseCase +import com.instructure.pandautils.utils.NetworkStateProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertFalse +import junit.framework.Assert.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +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.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class DashboardViewModelTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + private val lifecycleOwner: LifecycleOwner = mockk(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(lifecycleOwner) + + private val testDispatcher = UnconfinedTestDispatcher() + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val ensureDefaultWidgetsUseCase: EnsureDefaultWidgetsUseCase = mockk(relaxed = true) + private val observeWidgetMetadataUseCase: ObserveWidgetMetadataUseCase = mockk(relaxed = true) + + private lateinit var viewModel: DashboardViewModel + + @Before + fun setUp() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + Dispatchers.setMain(testDispatcher) + + every { networkStateProvider.isOnline() } returns true + coEvery { observeWidgetMetadataUseCase(Unit) } returns flowOf(emptyList()) + + viewModel = createViewModel() + } + + private fun createViewModel(): DashboardViewModel { + return DashboardViewModel(networkStateProvider, ensureDefaultWidgetsUseCase, observeWidgetMetadataUseCase) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun testInitialState() = runTest { + val state = viewModel.uiState.value + + assertFalse(state.loading) + assertEquals(null, state.error) + assertFalse(state.refreshing) + } + + @Test + fun testLoadDashboardSuccess() = runTest { + val state = viewModel.uiState.value + + assertEquals(false, state.loading) + assertEquals(null, state.error) + } + + @Test + fun testRefresh() = runTest { + viewModel.uiState.value.onRefresh() + + val state = viewModel.uiState.value + assertFalse(state.refreshing) + assertEquals(null, state.error) + } + + @Test + fun testRetry() = runTest { + viewModel.uiState.value.onRetry() + + val state = viewModel.uiState.value + assertFalse(state.loading) + assertEquals(null, state.error) + } + + @Test + fun testCallbacksExist() { + val state = viewModel.uiState.value + + assertTrue(state.onRefresh != null) + assertTrue(state.onRetry != null) + } + + @Test + fun testEnsureDefaultWidgetsCalledOnInit() = runTest { + coVerify { ensureDefaultWidgetsUseCase(Unit) } + } + + @Test + fun testWidgetsLoadedFromUseCase() = runTest { + val widgets = listOf( + WidgetMetadata("widget1", 0, true), + WidgetMetadata("widget2", 1, true) + ) + coEvery { observeWidgetMetadataUseCase(Unit) } returns flowOf(widgets) + + viewModel = createViewModel() + + val state = viewModel.uiState.value + assertEquals(2, state.widgets.size) + assertEquals("widget1", state.widgets[0].id) + assertEquals("widget2", state.widgets[1].id) + } + + @Test + fun testOnlyVisibleWidgetsShown() = runTest { + val widgets = listOf( + WidgetMetadata("widget1", 0, true), + WidgetMetadata("widget2", 1, false), + WidgetMetadata("widget3", 2, true) + ) + coEvery { observeWidgetMetadataUseCase(Unit) } returns flowOf(widgets) + + viewModel = createViewModel() + + val state = viewModel.uiState.value + assertEquals(2, state.widgets.size) + assertEquals("widget1", state.widgets[0].id) + assertEquals("widget3", state.widgets[1].id) + } + + @Test + fun testEmptyWidgetsList() = runTest { + coEvery { observeWidgetMetadataUseCase(Unit) } returns flowOf(emptyList()) + + viewModel = createViewModel() + + val state = viewModel.uiState.value + assertEquals(0, state.widgets.size) + } + + @Test + fun testLoadDashboardError() = runTest { + coEvery { observeWidgetMetadataUseCase(Unit) } throws Exception("Test error") + + viewModel = createViewModel() + + val state = viewModel.uiState.value + assertFalse(state.loading) + assertEquals("Test error", state.error) + } + + @Test + fun testShowSnackbarEmitsMessage() = runTest { + val message = "Test message" + val messages = mutableListOf() + + val job = launch(testDispatcher) { + viewModel.snackbarMessage.collect { snackbarMessage -> + messages.add(snackbarMessage) + } + } + + viewModel.showSnackbar(message) + advanceUntilIdle() + + assertEquals(1, messages.size) + assertEquals(message, messages[0].message) + assertEquals(null, messages[0].actionLabel) + assertEquals(null, messages[0].action) + + job.cancel() + } + + @Test + fun testShowSnackbarWithActionEmitsMessageAndAction() = runTest { + val message = "Test message" + val actionLabel = "Retry" + var actionInvoked = false + val action: () -> Unit = { actionInvoked = true } + + val messages = mutableListOf() + + val job = launch(testDispatcher) { + viewModel.snackbarMessage.collect { snackbarMessage -> + messages.add(snackbarMessage) + } + } + + viewModel.showSnackbar(message, actionLabel, action) + advanceUntilIdle() + + assertEquals(1, messages.size) + assertEquals(message, messages[0].message) + assertEquals(actionLabel, messages[0].actionLabel) + messages[0].action?.invoke() + assertTrue(actionInvoked) + + job.cancel() + } +} \ No newline at end of file diff --git a/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsNetworkDataSourceTest.kt b/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsNetworkDataSourceTest.kt index 682483dcc8..9726cf4650 100644 --- a/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsNetworkDataSourceTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/offline/assignmentdetails/AssignmentDetailsNetworkDataSourceTest.kt @@ -134,11 +134,13 @@ class AssignmentDetailsNetworkDataSourceTest { Assert.assertEquals(expected, assignmentResult) } - @Test(expected = IllegalStateException::class) - fun `Get LTI by launch url failure throws exception`() = runTest { + @Test + fun `Get LTI by launch url failure returns null`() = runTest { coEvery { assignmentInterface.getExternalToolLaunchUrl(any(), any(), any(), any(), any()) } returns DataResult.Fail() - dataSource.getExternalToolLaunchUrl(1, 1, 1, true) + val result = dataSource.getExternalToolLaunchUrl(1, 1, 1, true) + + Assert.assertNull(result) } @Test diff --git a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEffectHandlerTest.kt b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEffectHandlerTest.kt index 2707d6ad55..004b5cc127 100644 --- a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEffectHandlerTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEffectHandlerTest.kt @@ -39,6 +39,7 @@ import com.instructure.student.mobius.assignmentDetails.submissionDetails.ui.Sub import com.instructure.student.mobius.common.FlowSource import com.spotify.mobius.functions.Consumer import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk @@ -62,7 +63,8 @@ import java.io.File class SubmissionDetailsEffectHandlerTest : Assert() { private val view: SubmissionDetailsView = mockk(relaxed = true) private val repository: SubmissionDetailsRepository = mockk(relaxed = true) - private val effectHandler = SubmissionDetailsEffectHandler(repository).apply { view = this@SubmissionDetailsEffectHandlerTest.view } + private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val effectHandler = SubmissionDetailsEffectHandler(repository, apiPrefs).apply { view = this@SubmissionDetailsEffectHandlerTest.view } private val eventConsumer: Consumer = mockk(relaxed = true) private val connection = effectHandler.connect(eventConsumer) private val testDispatcher = UnconfinedTestDispatcher() @@ -492,4 +494,61 @@ class SubmissionDetailsEffectHandlerTest : Assert() { assertEquals(expectedEvent, deferred.await()) } + @Test + fun `loadData uses global user ID for cross-shard course`() { + val courseId = 1234L + val userId = 5678L + val courseShardId = "7053" + val tokenShardId = "8000" + val expectedGlobalUserId = 80000000000005678L + + val assignment = Assignment().copy(submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_QUIZ.apiString)) + val submission = Submission() + val user = User(id = userId) + + // Mock apiPrefs to return different shard IDs + every { apiPrefs.user } returns user + every { apiPrefs.shardIds } returns mutableMapOf(courseId to courseShardId) + every { apiPrefs.accessToken } returns "$tokenShardId~abcdef1234567890" + + coEvery { repository.getObserveeEnrollments(any()) } returns DataResult.Success(listOf()) + coEvery { repository.getAssignment(any(), any(), any()) } returns DataResult.Success(assignment) + coEvery { repository.getSingleSubmission(any(), any(), any(), any()) } returns DataResult.Success(submission) + coEvery { repository.getCourseFeatures(any(), any()) } returns DataResult.Success(listOf("assignments_2_student")) + + connection.accept(SubmissionDetailsEffect.LoadData(courseId, assignment.id)) + + // Verify that getSingleSubmission was called with the global user ID + coVerify(timeout = 100) { + repository.getSingleSubmission(courseId, assignment.id, expectedGlobalUserId, true) + } + } + + @Test + fun `loadData uses original user ID for same-shard course`() { + val courseId = 1234L + val userId = 5678L + val shardId = "7053" + + val assignment = Assignment().copy(submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_QUIZ.apiString)) + val submission = Submission() + val user = User(id = userId) + + // Mock apiPrefs to return same shard ID + every { apiPrefs.user } returns user + every { apiPrefs.shardIds } returns mutableMapOf(courseId to shardId) + every { apiPrefs.accessToken } returns "$shardId~abcdef1234567890" + + coEvery { repository.getObserveeEnrollments(any()) } returns DataResult.Success(listOf()) + coEvery { repository.getAssignment(any(), any(), any()) } returns DataResult.Success(assignment) + coEvery { repository.getSingleSubmission(any(), any(), any(), any()) } returns DataResult.Success(submission) + coEvery { repository.getCourseFeatures(any(), any()) } returns DataResult.Success(listOf("assignments_2_student")) + + connection.accept(SubmissionDetailsEffect.LoadData(courseId, assignment.id)) + + // Verify that getSingleSubmission was called with the original user ID (not converted) + coVerify(timeout = 100) { + repository.getSingleSubmission(courseId, assignment.id, userId, true) + } + } } diff --git a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEmptyContentEffectHandlerTest.kt b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEmptyContentEffectHandlerTest.kt index 6dfa3f0353..542df8b602 100644 --- a/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEmptyContentEffectHandlerTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/assignment/details/submissionDetails/SubmissionDetailsEmptyContentEffectHandlerTest.kt @@ -261,7 +261,7 @@ class SubmissionDetailsEmptyContentEffectHandlerTest : Assert() { "Path", mediaSource = "audio_recorder" ) - } returns Unit + } returns 1L connection.accept(SubmissionDetailsEmptyContentEffect.UploadAudioSubmission(file, course, assignment)) diff --git a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListPresenterTest.kt b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListPresenterTest.kt index aea2c330a2..f879f53029 100644 --- a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListPresenterTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListPresenterTest.kt @@ -25,6 +25,7 @@ import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.utils.color import com.instructure.student.R +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType import com.instructure.student.mobius.conferences.conference_list.ConferenceListModel import com.instructure.student.mobius.conferences.conference_list.ConferenceListPresenter import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListItemViewState @@ -97,7 +98,11 @@ class ConferenceListPresenterTest : Assert() { // Generate state val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded - val expectedHeader = ConferenceListItemViewState.ConferenceHeader(context.getString(R.string.newConferences)) + val expectedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.newConferences), + ConferenceHeaderType.NEW_CONFERENCES, + true + ) val expectedConferenceItem = ConferenceListPresenter.mapItemState(canvasContext.color, conference, context) // Should have two list items - one header and one conference @@ -116,7 +121,11 @@ class ConferenceListPresenterTest : Assert() { // Generate state val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded - val expectedHeader = ConferenceListItemViewState.ConferenceHeader(context.getString(R.string.concludedConferences)) + val expectedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.concludedConferences), + ConferenceHeaderType.CONCLUDED_CONFERENCES, + true + ) val expectedConferenceItem = ConferenceListPresenter.mapItemState(canvasContext.color, conference, context) // Should have two list items - one header and one conference @@ -137,8 +146,16 @@ class ConferenceListPresenterTest : Assert() { // Generate state val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded - val newHeader = ConferenceListItemViewState.ConferenceHeader(context.getString(R.string.newConferences)) - val concludedHeader = ConferenceListItemViewState.ConferenceHeader(context.getString(R.string.concludedConferences)) + val newHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.newConferences), + ConferenceHeaderType.NEW_CONFERENCES, + true + ) + val concludedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.concludedConferences), + ConferenceHeaderType.CONCLUDED_CONFERENCES, + true + ) val inProgressItem = ConferenceListPresenter.mapItemState(canvasContext.color, inProgress, context) val notStartedItem = ConferenceListPresenter.mapItemState(canvasContext.color, notStarted, context) val concludedItem = ConferenceListPresenter.mapItemState(canvasContext.color, concluded, context) @@ -236,4 +253,81 @@ class ConferenceListPresenterTest : Assert() { assertEquals(state, expected) } + + @Test + fun `Returns only header when new conferences section is collapsed`() { + // Set up model with a not-started conference but collapsed state + val conference = Conference() + val result = DataResult.Success(listOf(conference)) + val model = ConferenceListModel(canvasContext, listResult = result, isNewConferencesExpanded = false) + + // Generate state + val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded + + val expectedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.newConferences), + ConferenceHeaderType.NEW_CONFERENCES, + false + ) + + // Should have only the header, no conference items + assertEquals(state.itemStates.size, 1) + assertEquals(state.itemStates[0], expectedHeader) + } + + @Test + fun `Returns only header when concluded conferences section is collapsed`() { + // Set up model with a concluded conference but collapsed state + val conference = Conference(startedAt = Date(), endedAt = Date()) + val result = DataResult.Success(listOf(conference)) + val model = ConferenceListModel(canvasContext, listResult = result, isConcludedConferencesExpanded = false) + + // Generate state + val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded + + val expectedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.concludedConferences), + ConferenceHeaderType.CONCLUDED_CONFERENCES, + false + ) + + // Should have only the header, no conference items + assertEquals(state.itemStates.size, 1) + assertEquals(state.itemStates[0], expectedHeader) + } + + @Test + fun `Sections can be collapsed independently`() { + // Set up model with both types but new conferences collapsed + val notStarted = Conference(startedAt = null, endedAt = null) + val concluded = Conference(startedAt = Date(), endedAt = Date()) + val result = DataResult.Success(listOf(notStarted, concluded)) + val model = ConferenceListModel( + canvasContext, + listResult = result, + isNewConferencesExpanded = false, + isConcludedConferencesExpanded = true + ) + + // Generate state + val state = ConferenceListPresenter.present(model, context) as ConferenceListViewState.Loaded + + val newHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.newConferences), + ConferenceHeaderType.NEW_CONFERENCES, + false + ) + val concludedHeader = ConferenceListItemViewState.ConferenceHeader( + context.getString(R.string.concludedConferences), + ConferenceHeaderType.CONCLUDED_CONFERENCES, + true + ) + val concludedItem = ConferenceListPresenter.mapItemState(canvasContext.color, concluded, context) + + // Should have collapsed new header (no items), expanded concluded header with item + assertEquals(state.itemStates.size, 3) + assertEquals(state.itemStates[0], newHeader) + assertEquals(state.itemStates[1], concludedHeader) + assertEquals(state.itemStates[2], concludedItem) + } } diff --git a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListUpdateTest.kt b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListUpdateTest.kt index 2999d775ac..dbde35fbea 100644 --- a/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListUpdateTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/conferences/conference_list/ConferenceListUpdateTest.kt @@ -22,6 +22,7 @@ import com.instructure.canvasapi2.models.Conference import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult +import com.instructure.student.mobius.conferences.conference_list.ConferenceHeaderType import com.instructure.student.mobius.conferences.conference_list.ConferenceListEffect import com.instructure.student.mobius.conferences.conference_list.ConferenceListEvent import com.instructure.student.mobius.conferences.conference_list.ConferenceListModel @@ -152,4 +153,85 @@ class ConferenceListUpdateTest : Assert() { ) ) } + + @Test + fun `HeaderClicked event with NEW_CONFERENCES toggles isNewConferencesExpanded from true to false`() { + val inputModel = initModel.copy(isNewConferencesExpanded = true) + val expectedModel = inputModel.copy(isNewConferencesExpanded = false) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.NEW_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } + + @Test + fun `HeaderClicked event with NEW_CONFERENCES toggles isNewConferencesExpanded from false to true`() { + val inputModel = initModel.copy(isNewConferencesExpanded = false) + val expectedModel = inputModel.copy(isNewConferencesExpanded = true) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.NEW_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } + + @Test + fun `HeaderClicked event with CONCLUDED_CONFERENCES toggles isConcludedConferencesExpanded from true to false`() { + val inputModel = initModel.copy(isConcludedConferencesExpanded = true) + val expectedModel = inputModel.copy(isConcludedConferencesExpanded = false) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.CONCLUDED_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } + + @Test + fun `HeaderClicked event with CONCLUDED_CONFERENCES toggles isConcludedConferencesExpanded from false to true`() { + val inputModel = initModel.copy(isConcludedConferencesExpanded = false) + val expectedModel = inputModel.copy(isConcludedConferencesExpanded = true) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.CONCLUDED_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } + + @Test + fun `HeaderClicked event only toggles the targeted section`() { + val inputModel = initModel.copy( + isNewConferencesExpanded = true, + isConcludedConferencesExpanded = false + ) + val expectedModel = inputModel.copy( + isNewConferencesExpanded = false, + isConcludedConferencesExpanded = false + ) + updateSpec + .given(inputModel) + .whenEvent(ConferenceListEvent.HeaderClicked(ConferenceHeaderType.NEW_CONFERENCES)) + .then( + assertThatNext( + NextMatchers.hasModel(expectedModel), + NextMatchers.hasNoEffects() + ) + ) + } } diff --git a/apps/student/src/test/java/com/instructure/student/test/util/RouterUtilsTest.kt b/apps/student/src/test/java/com/instructure/student/test/util/RouterUtilsTest.kt index 32710d6e65..57f4ea4d9d 100644 --- a/apps/student/src/test/java/com/instructure/student/test/util/RouterUtilsTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/util/RouterUtilsTest.kt @@ -21,6 +21,7 @@ import androidx.fragment.app.FragmentActivity import androidx.test.ext.junit.runners.AndroidJUnit4 import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.interactions.router.Route import com.instructure.interactions.router.RouteContext import com.instructure.interactions.router.RouterParams @@ -46,8 +47,11 @@ import com.instructure.student.mobius.assignmentDetails.submissionDetails.ui.Sub import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListRepositoryFragment import com.instructure.student.mobius.syllabus.ui.SyllabusRepositoryFragment import com.instructure.student.router.RouteMatcher +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import junit.framework.TestCase +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -56,6 +60,12 @@ class RouterUtilsTest : TestCase() { private val activity: FragmentActivity = mockk(relaxed = true) + @Before + fun setup() { + mockkObject(RemoteConfigUtils) + every { RemoteConfigUtils.getString(any()) } returns "false" + } + @Test fun testCanRouteInternally_misc() { // Home diff --git a/apps/student/src/test/java/com/instructure/student/widget/todo/ToDoWidgetRepositoryTest.kt b/apps/student/src/test/java/com/instructure/student/widget/todo/ToDoWidgetRepositoryTest.kt index 4781385a5b..50a81c5dc3 100644 --- a/apps/student/src/test/java/com/instructure/student/widget/todo/ToDoWidgetRepositoryTest.kt +++ b/apps/student/src/test/java/com/instructure/student/widget/todo/ToDoWidgetRepositoryTest.kt @@ -25,10 +25,11 @@ import com.instructure.canvasapi2.models.PlannableType import com.instructure.canvasapi2.models.PlannerItem import com.instructure.canvasapi2.models.PlannerOverride import com.instructure.canvasapi2.models.SubmissionState +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.LinkHeaders -import com.instructure.pandautils.room.calendar.daos.CalendarFilterDao -import com.instructure.pandautils.room.calendar.entities.CalendarFilterEntity +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -43,15 +44,16 @@ class ToDoWidgetRepositoryTest { private val plannerApi: PlannerAPI.PlannerInterface = mockk(relaxed = true) private val coursesApi: CourseAPI.CoursesInterface = mockk(relaxed = true) - private val calendarFilterDao: CalendarFilterDao = mockk(relaxed = true) + private val toDoFilterDao: ToDoFilterDao = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) - private val repository: ToDoWidgetRepository = ToDoWidgetRepository(plannerApi, coursesApi, calendarFilterDao) + private val repository: ToDoWidgetRepository = ToDoWidgetRepository(plannerApi, coursesApi, toDoFilterDao, apiPrefs) @Test fun `Returns failed result when planner api request fails`() = runTest { coEvery { plannerApi.getPlannerItems(any(), any(), any(), any(), any()) } returns DataResult.Fail() - val result = repository.getPlannerItems("2023-1-1", "2023-1-2", emptyList(), true) + val result = repository.getPlannerItems("2023-1-1", "2023-1-2", true) assertEquals(DataResult.Fail(), result) } @@ -71,7 +73,7 @@ class ToDoWidgetRepositoryTest { coEvery { plannerApi.getPlannerItems(any(), any(), any(), any(), any()) } returns DataResult.Success(plannerItems) - val result = repository.getPlannerItems("2023-1-1", "2023-1-2", emptyList(), true) + val result = repository.getPlannerItems("2023-1-1", "2023-1-2", true) assertEquals(DataResult.Success(plannerItems.minus(listOf(filteredItem, filteredItem2).toSet())), result) } @@ -94,7 +96,7 @@ class ToDoWidgetRepositoryTest { ) coEvery { plannerApi.nextPagePlannerItems(eq("next"), any()) } returns DataResult.Success(plannerItems2) - val result = repository.getPlannerItems("2023-1-1", "2023-1-2", emptyList(), true) + val result = repository.getPlannerItems("2023-1-1", "2023-1-2", true) assertEquals(DataResult.Success(plannerItems1.plus(plannerItems2)), result) } @@ -141,14 +143,16 @@ class ToDoWidgetRepositoryTest { } @Test - fun `Gets calendar filters frm db`() = runTest { - val filters = CalendarFilterEntity(1, "domain", "1", -1, setOf("filter1")) - coEvery { calendarFilterDao.findByUserIdAndDomain(any(), any()) } returns filters + fun `Gets todo filters from db`() = runTest { + val filters = ToDoFilterEntity(1, "domain", 1) + coEvery { apiPrefs.fullDomain } returns "domain" + coEvery { apiPrefs.user?.id } returns 1 + coEvery { toDoFilterDao.findByUser(any(), any()) } returns filters - val result = repository.getCalendarFilters(1, "domain") + val result = repository.getToDoFilters() assertEquals(filters, result) - coVerify { calendarFilterDao.findByUserIdAndDomain(1, "domain") } + coVerify { toDoFilterDao.findByUser("domain", 1) } } private fun createPlannerItem( 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 a56c70083b..738f6367ed 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 @@ -31,7 +31,7 @@ import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.DateHelper import com.instructure.canvasapi2.utils.Failure import com.instructure.canvasapi2.utils.toApiString -import com.instructure.pandautils.room.calendar.entities.CalendarFilterEntity +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity import com.instructure.pandautils.utils.color import com.instructure.student.R import com.instructure.student.widget.glance.WidgetState @@ -68,9 +68,13 @@ class ToDoWidgetUpdaterTest { ContextKeeper.appContext = mockk(relaxed = true) mockkObject(DateHelper) every { DateHelper.getPreferredTimeFormat(any()) } returns SimpleDateFormat("HH:mm", Locale.getDefault()) - every { context.getString(R.string.widgetDueDate, any()) } answers { "Due at ${secondArg>()[0]}" } every { context.getString(R.string.userCalendarToDo) } returns "To Do" every { context.getString(R.string.widgetAllDay) } returns "All day" + + // Set up default mocks + every { apiPrefs.user } returns User(1L) + coEvery { repository.getToDoFilters() } returns ToDoFilterEntity(userDomain = "domain", userId = 1L, personalTodos = true, calendarEvents = true) + coEvery { repository.getCourses(any()) } returns emptyList() } @After @@ -80,7 +84,7 @@ class ToDoWidgetUpdaterTest { @Test fun `Emits Loading state when called`() = runTest { - val flow = updater.updateData(context) + val flow = updater.updateData(context, forceNetwork = true) assertEquals(WidgetState.Loading, flow.first().state) } @@ -88,31 +92,31 @@ class ToDoWidgetUpdaterTest { fun `Emits NotLoggedIn state when user is null`() = runTest { every { apiPrefs.user } returns null - val flow = updater.updateData(context) + val flow = updater.updateData(context, forceNetwork = true) assertEquals(WidgetState.NotLoggedIn, flow.last().state) } @Test fun `Emits NotLoggedIn state when api call gets authorization error`() = runTest { - coEvery { repository.getPlannerItems(any(), any(), any(), any()) } returns DataResult.Fail(Failure.Authorization()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Fail(Failure.Authorization()) - val flow = updater.updateData(context) + val flow = updater.updateData(context, forceNetwork = true) assertEquals(WidgetState.NotLoggedIn, flow.last().state) } @Test fun `Emits Error state when api calls fail`() = runTest { - coEvery { repository.getPlannerItems(any(), any(), any(), any()) } returns DataResult.Fail() + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Fail() - val flow = updater.updateData(context) + val flow = updater.updateData(context, forceNetwork = true) assertEquals(WidgetState.Error, flow.last().state) } @Test fun `Emits Empty state when api returns empty list`() = runTest { - coEvery { repository.getPlannerItems(any(), any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) - val flow = updater.updateData(context) + val flow = updater.updateData(context, forceNetwork = true) assertEquals(WidgetState.Empty, flow.last().state) } @@ -144,20 +148,11 @@ class ToDoWidgetUpdaterTest { ) coEvery { repository.getCourses(any()) } returns listOf(Course(1, courseCode = "CODE")) - coEvery { repository.getPlannerItems(any(), any(), any(), any()) } returns DataResult.Success(listOf(assignmentItem, toDoItem, calendarEvent)) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(assignmentItem, toDoItem, calendarEvent)) val expected = ToDoWidgetUiState( WidgetState.Content, listOf( - WidgetPlannerItem( - LocalDate.of(2024, 1, 5), - R.drawable.ic_assignment, - assignmentItem.canvasContext.color, - "CODE", - "Plannable 1", - "Due at 02:00", - "https://htmlurl.com" - ), WidgetPlannerItem( LocalDate.of(2023, 10, 1), R.drawable.ic_todo, @@ -167,6 +162,15 @@ class ToDoWidgetUpdaterTest { "12:00", "/todos/2" ), + WidgetPlannerItem( + LocalDate.of(2024, 1, 5), + R.drawable.ic_assignment, + assignmentItem.canvasContext.color, + "CODE", + "Plannable 1", + "02:00", + "https://htmlurl.com" + ), WidgetPlannerItem( LocalDate.of(2025, 5, 21), R.drawable.ic_calendar, @@ -178,7 +182,7 @@ class ToDoWidgetUpdaterTest { ) ) ) - val flow = updater.updateData(context) + val flow = updater.updateData(context, forceNetwork = true) assertEquals(expected, flow.last()) } @@ -242,7 +246,7 @@ class ToDoWidgetUpdaterTest { ) coEvery { repository.getCourses(any()) } returns listOf(Course(1, courseCode = "CODE")) - coEvery { repository.getPlannerItems(any(), any(), any(), any()) } returns DataResult.Success(listOf( + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf( submittedAssignmentItem, submittedDiscussionItem, submittedSubAssignmentItem, @@ -255,15 +259,6 @@ class ToDoWidgetUpdaterTest { val expected = ToDoWidgetUiState( WidgetState.Content, listOf( - WidgetPlannerItem( - LocalDate.of(2024, 1, 5), - R.drawable.ic_discussion, - subAssignmentItem.canvasContext.color, - "CODE", - "Plannable 1", - "Due at 02:00", - "https://htmlurl.com" - ), WidgetPlannerItem( LocalDate.of(2023, 10, 1), R.drawable.ic_todo, @@ -273,6 +268,15 @@ class ToDoWidgetUpdaterTest { "12:00", "/todos/2" ), + WidgetPlannerItem( + LocalDate.of(2024, 1, 5), + R.drawable.ic_discussion, + subAssignmentItem.canvasContext.color, + "CODE", + "Plannable 1", + "02:00", + "https://htmlurl.com" + ), WidgetPlannerItem( LocalDate.of(2025, 5, 21), R.drawable.ic_calendar, @@ -284,29 +288,27 @@ class ToDoWidgetUpdaterTest { ) ) ) - val flow = updater.updateData(context) + val flow = updater.updateData(context, forceNetwork = true) assertEquals(expected, flow.last()) } @Test - fun `Gets calendar filters and calls api with the correct params`() = runTest { - val now = LocalDate.now().atStartOfDay() - coEvery { apiPrefs.user } returns User(1L) - coEvery { apiPrefs.fullDomain } returns "domain" - coEvery { repository.getCalendarFilters(1L, "domain") } returns CalendarFilterEntity( - 1, - "domain", - "1", - -1, - setOf("course_1", "group_1", "user_1") - ) + fun `Gets todo filters and calls api with the correct params`() = runTest { + every { apiPrefs.user } returns User(1L) + every { apiPrefs.fullDomain } returns "domain" + val filters = ToDoFilterEntity(userDomain = "domain", userId = 1L) + coEvery { repository.getToDoFilters() } returns filters + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + + updater.updateData(context, forceNetwork = true).last() + + val startDate = filters.pastDateRange.calculatePastDateRange().toApiString() + val endDate = filters.futureDateRange.calculateFutureDateRange().toApiString() - updater.updateData(context).last() coVerify { repository.getPlannerItems( - startDate = now.minusDays(28).toApiString().orEmpty(), - endDate = now.plusDays(28).toApiString().orEmpty(), - contextCodes = listOf("course_1", "group_1", "user_1"), + startDate = startDate, + endDate = endDate, forceNetwork = true ) } @@ -323,7 +325,7 @@ class ToDoWidgetUpdaterTest { contextName: String? = null, allDay: Boolean = false, submitted: Boolean = false, - markedComplete: Boolean = false + markedComplete: Boolean? = null ): PlannerItem { val plannable = Plannable( id = plannableId, @@ -351,7 +353,11 @@ class ToDoWidgetUpdaterTest { plannableDate = date, htmlUrl = "https://htmlurl.com", submissionState = SubmissionState(submitted = submitted), - plannerOverride = PlannerOverride(plannableType = plannableType, plannableId = plannableId, markedComplete = markedComplete), + plannerOverride = if (markedComplete != null) PlannerOverride( + plannableType = plannableType, + plannableId = plannableId, + markedComplete = markedComplete + ) else null, newActivity = false ) } diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index 29113262da..6c81f7f6cc 100644 --- a/apps/teacher/build.gradle +++ b/apps/teacher/build.gradle @@ -55,8 +55,8 @@ android { defaultConfig { minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 82 - versionName = '2.0.1' + versionCode = 84 + versionName = '2.2.0' 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/compose/CustomStatusesE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/CustomStatusesE2ETest.kt new file mode 100644 index 0000000000..2e5f034a7e --- /dev/null +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/compose/CustomStatusesE2ETest.kt @@ -0,0 +1,147 @@ +package com.instructure.teacher.ui.e2e.compose + +import android.util.Log +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.E2E +import com.instructure.canvas.espresso.pressBackButton +import com.instructure.dataseeding.api.AssignmentsApi +import com.instructure.dataseeding.api.CustomStatusApi +import com.instructure.dataseeding.api.SubmissionsApi +import com.instructure.dataseeding.model.GradingType +import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.util.CanvasNetworkAdapter.adminToken +import com.instructure.dataseeding.util.days +import com.instructure.dataseeding.util.fromNow +import com.instructure.dataseeding.util.iso8601 +import com.instructure.teacher.ui.pages.classic.PeopleListPage +import com.instructure.teacher.ui.pages.classic.PersonContextPage +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.After +import org.junit.Test +import java.lang.Thread.sleep + +@HiltAndroidTest +class CustomStatusesE2ETest: TeacherComposeTest() { + + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + private var customStatusId: String? = null + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.CUSTOM_STATUSES, TestCategory.E2E) + fun testCustomStatusesE2E() { + + 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 a custom status ('AMAZING') with the admin user.") + customStatusId = CustomStatusApi.upsertCustomGradeStatus(adminToken, name = "AMAZING", color = "#FF0000") + + Log.d(PREPARATION_TAG, "Seeding 'Text Entry' assignment for '${course.name}' course.") + val testAssignment = AssignmentsApi.createAssignment(course.id, teacher.token, gradingType = GradingType.POINTS, pointsPossible = 15.0, dueAt = 1.days.fromNow.iso8601, submissionTypes = listOf(SubmissionType.ONLINE_TEXT_ENTRY)) + + Log.d(PREPARATION_TAG, "Student submits the assignment.") + SubmissionsApi.submitCourseAssignment( + courseId = course.id, + studentToken = student.token, + assignmentId = testAssignment.id, + submissionType = SubmissionType.ONLINE_TEXT_ENTRY + ) + + Log.d(PREPARATION_TAG, "Teacher grades submission with custom status 'AMAZING'.") + SubmissionsApi.gradeSubmission( + teacherToken = teacher.token, + courseId = course.id, + assignmentId = testAssignment.id, + studentId = student.id, + postedGrade = "12", + customGradeStatusId = customStatusId + ) + + 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.") + dashboardPage.openCourse(course.name) + + Log.d(STEP_TAG, "Navigate to the People List page.") + courseBrowserPage.openPeopleTab() + + Log.d(ASSERTION_TAG, "Assert that '${student.name}' student is displayed and it is really a student person.") + peopleListPage.assertPersonRole(student.name, PeopleListPage.UserRole.STUDENT) + + Log.d(STEP_TAG, "Click on '${student.name}', the student person.") + peopleListPage.clickPerson(student) + + Log.d(ASSERTION_TAG, "Assert the that the student course info and the corresponding section name is displayed on Context Page.") + personContextPage.assertDisplaysCourseInfo(course) + personContextPage.assertSectionNameView(PersonContextPage.UserRole.STUDENT) + + Log.d(ASSERTION_TAG, "Assert that the assignment '${testAssignment.name}' is displayed with the custom status 'AMAZING' on Person Context Page.") + personContextPage.assertAssignmentStatus(testAssignment.name, "AMAZING") + + Log.d(STEP_TAG, "Click on the '${testAssignment.name}' assignment to open it's details.") + personContextPage.clickAssignment(testAssignment.name) + + Log.d(ASSERTION_TAG, "Assert that the SpeedGrader Grade Page is displayed with the custom status 'AMAZING' for the student '${student.name}', and the selected status text is 'AMAZING'.") + speedGraderGradePage.assertCurrentStatus("AMAZING", student.name) + speedGraderGradePage.assertSelectedStatusText("AMAZING") + + Log.d(STEP_TAG, "Navigate back to the Course Browser page.") + pressBackButton(3) + + Log.d(STEP_TAG, "Navigate to '${course.name}' course's Assignments Tab.") + courseBrowserPage.openAssignmentsTab() + + Log.d(STEP_TAG, "Click on '${testAssignment.name}' assignment.") + assignmentListPage.clickAssignment(testAssignment) + + Log.d(STEP_TAG, "Open the 'All Submissions' page and click on the filter icon on the top-right corner.") + assignmentDetailsPage.clickAllSubmissions() + sleep(3000) // Sleep added to wait for the All Submissions page to load + + Log.d(ASSERTION_TAG, "Assert that the submission of the student '${student.name}' is displayed with the custom status tag 'AMAZING' on Assignment Submission List Page.") + assignmentSubmissionListPage.assertCustomStatusTag("AMAZING") + + Log.d(STEP_TAG, "Click on the submission of the student '${student.name}' to open SpeedGrader Grade Page.") + assignmentSubmissionListPage.clickSubmission(student) + + Log.d(ASSERTION_TAG, "Assert that the SpeedGrader Grade Page is displayed with the custom status 'AMAZING' for the student '${student.name}', and the selected status text is 'AMAZING'.") + speedGraderGradePage.assertCurrentStatus("AMAZING", student.name) + speedGraderGradePage.assertSelectedStatusText("AMAZING") + + Log.d(STEP_TAG, "Change the status from 'AMAZING' to 'None'.") + speedGraderGradePage.selectStatus("None") + sleep(3000) // Sleep added to wait for the status to be updated + + Log.d(ASSERTION_TAG, "The current status became 'Graded' as the submission is already graded.") + speedGraderGradePage.assertCurrentStatus("Graded", student.name) + } + + @After + fun tearDown() { + customStatusId?.let { + try { + Log.d(PREPARATION_TAG, "Cleaning up the custom status we created with '$it' ID previously because 3 is the maximum limit of custom statuses.") + CustomStatusApi.deleteCustomGradeStatus(adminToken, it) + Log.d(PREPARATION_TAG, "Successfully deleted custom status with ID: $it") + } catch (e: Exception) { + Log.e(PREPARATION_TAG, "Failed to delete custom status with ID: $it", e) + throw e + } + } ?: Log.w(PREPARATION_TAG, "No custom status ID to clean up - this might indicate the test failed during setup") + } +} \ No newline at end of file diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/PersonContextPage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/PersonContextPage.kt index bd4a8c3dbb..dc4ced74c7 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/PersonContextPage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/classic/PersonContextPage.kt @@ -24,12 +24,16 @@ import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.assertContainsText import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertHasText +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withChild +import com.instructure.espresso.page.withId import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo import com.instructure.teacher.R import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not @@ -68,6 +72,25 @@ open class PersonContextPage : BasePage(R.id.studentContextPage) { courseName.assertHasText(courseNameText) } + /** + * Asserts the assignment status for a given assignment name. + * + * @param assignmentName The name of the assignment. + * @param expectedStatus The expected status of the assignment. + */ + fun assertAssignmentStatus(assignmentName: String, expectedStatus: String) { + onView(withText(expectedStatus) + withParent(withChild(withText(assignmentName)))).scrollTo().assertDisplayed() + } + + /** + * Clicks on an assignment with the given name. + * + * @param assignmentName The name of the assignment to click. + */ + fun clickAssignment(assignmentName: String) { + onView(withText(assignmentName) + withId(R.id.assignmentTitle)).click() + } + /** * Asserts the section name view based on the user role. * diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/SpeedGraderGradePage.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/SpeedGraderGradePage.kt index 78ab0a180d..34eac86af6 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/SpeedGraderGradePage.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/pages/compose/SpeedGraderGradePage.kt @@ -24,6 +24,8 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertTextContains +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 @@ -278,6 +280,28 @@ class SpeedGraderGradePage(private val composeTestRule: ComposeTestRule) : BaseP } } + /** + * Asserts that the current status is displayed for the specified student. + * + * @param expectedStatus The expected status to be displayed. + * @param studentName The name of the student associated with the status. + */ + fun assertCurrentStatus(expectedStatus: String, studentName: String) { + composeTestRule.onNode(hasText(expectedStatus) and hasTestTag("submissionStatusLabel") and hasAnyAncestor(hasAnyDescendant(hasText(studentName))), useUnmergedTree = true).assertIsDisplayed() + } + + /** + * Selects a status from the status dropdown in the Compose UI. + * + * @param statusText The status text to be selected from the dropdown. + */ + fun selectStatus(statusText: String) { + composeTestRule.onNodeWithTag("speedGraderStatusDropdown", useUnmergedTree = true).performClick() + composeTestRule.waitForIdle() + composeTestRule.onNode(hasText(statusText), useUnmergedTree = true).performClick() + composeTestRule.waitForIdle() + } + /** * Asserts that the 'Days Late' label is displayed and the specified days late value is shown. * 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 84ca934b28..9b706c2d02 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 @@ -158,6 +158,9 @@ class InitActivity : BasePresenterActivity + // Cancel any active drag on dashboard before switching tabs + (supportFragmentManager.findFragmentByTag(DashboardFragment::class.java.simpleName) as? DashboardFragment)?.cancelCardDrag() + selectedTab = when (item.itemId) { R.id.tab_courses -> { addCoursesFragment() diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoListModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoListModule.kt new file mode 100644 index 0000000000..382165441c --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoListModule.kt @@ -0,0 +1,37 @@ +/* + * 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.teacher.di + +import com.instructure.pandautils.features.todolist.ToDoListViewModelBehavior +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + + +@Module +@InstallIn(ViewModelComponent::class) +class ToDoListModule { + + @Provides + fun provideToDoListViewModelBehavior(): ToDoListViewModelBehavior { + return object : ToDoListViewModelBehavior { + override fun updateWidget(forceRefresh: Boolean) = Unit + } + } +} \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt index 49984b9398..19ae28e1a9 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt @@ -20,6 +20,8 @@ package com.instructure.teacher.di import androidx.fragment.app.FragmentActivity import com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior import com.instructure.pandautils.features.calendartodo.details.ToDoRouter +import com.instructure.pandautils.features.todolist.DefaultToDoListRouter +import com.instructure.pandautils.features.todolist.ToDoListRouter import com.instructure.teacher.features.calendartodo.TeacherToDoRouter import dagger.Module import dagger.Provides @@ -35,6 +37,11 @@ class ToDoModule { fun provideToDoRouter(activity: FragmentActivity): ToDoRouter { return TeacherToDoRouter(activity) } + + @Provides + fun provideToDoListRouter(): ToDoListRouter { + return DefaultToDoListRouter() + } } @Module diff --git a/apps/teacher/src/main/java/com/instructure/teacher/dialog/PeopleListFilterDialog.kt b/apps/teacher/src/main/java/com/instructure/teacher/dialog/PeopleListFilterDialog.kt index 1f85f29792..d34e181fba 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/dialog/PeopleListFilterDialog.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/dialog/PeopleListFilterDialog.kt @@ -43,15 +43,9 @@ import com.instructure.pandautils.utils.nonNullArgs import com.instructure.teacher.R import com.instructure.teacher.adapters.PeopleFilterAdapter import kotlinx.coroutines.Job -import kotlin.properties.Delegates class PeopleListFilterDialog : BaseCanvasAppCompatDialogFragment() { - init { - retainInstance = true - } - private var recyclerView: RecyclerView? = null - private var finishedCallback: (canvasContexts: ArrayList) -> Unit by Delegates.notNull() private var canvasContext: CanvasContext? = null private var canvasContextMap: HashMap = HashMap() private var canvasContextIdList: ArrayList = ArrayList() @@ -73,6 +67,14 @@ class PeopleListFilterDialog : BaseCanvasAppCompatDialogFragment() { canvasContext = nonNullArgs.getParcelable(Const.CANVAS_CONTEXT) shouldIncludeGroups = nonNullArgs.getBoolean(Const.GROUPS) + // Restore selected contexts from saved state + savedInstanceState?.let { + val selectedContexts = it.getParcelableArrayList(SAVED_SELECTED_CONTEXTS_KEY) + selectedContexts?.forEach { context -> + canvasContextMap[context] = true + } + } + recyclerView = view.findViewById(R.id.recyclerView) recyclerView?.layoutManager = LinearLayoutManager(requireContext()) @@ -81,8 +83,11 @@ class PeopleListFilterDialog : BaseCanvasAppCompatDialogFragment() { .setTitle(getString(R.string.filterBy)) .setView(view) .setPositiveButton(getString(android.R.string.ok)) { _, _ -> - // Get the list of checked Canvas Contexts from the map - finishedCallback(canvasContextMap.filter { it.value }.keys.toMutableList() as ArrayList) + val selectedContexts = canvasContextMap.filter { it.value }.keys.toMutableList() as ArrayList + val result = Bundle().apply { + putParcelableArrayList(RESULT_SELECTED_CONTEXTS, selectedContexts) + } + parentFragmentManager.setFragmentResult(REQUEST_KEY, result) } .setNegativeButton(getString(R.string.cancel), null) .create() @@ -157,15 +162,23 @@ class PeopleListFilterDialog : BaseCanvasAppCompatDialogFragment() { return canvasContexts } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + val selectedContexts = canvasContextMap.filter { it.value }.keys.toCollection(ArrayList()) + outState.putParcelableArrayList(SAVED_SELECTED_CONTEXTS_KEY, selectedContexts) + } + override fun onDestroyView() { - // Fix for rotation bug mApiCalls?.cancel() - dialog?.let { if (retainInstance) it.setDismissMessage(null) } super.onDestroyView() } companion object { - fun getInstance(manager: FragmentManager, canvasContextIdList: ArrayList, canvasContext: CanvasContext, shouldIncludeGroups: Boolean, callback: (canvasContexts: ArrayList) -> Unit) : PeopleListFilterDialog { + const val REQUEST_KEY = "PeopleListFilterDialog" + const val RESULT_SELECTED_CONTEXTS = "selected_contexts" + private const val SAVED_SELECTED_CONTEXTS_KEY = "saved_selected_contexts" + + fun getInstance(manager: FragmentManager, canvasContextIdList: ArrayList, canvasContext: CanvasContext, shouldIncludeGroups: Boolean) : PeopleListFilterDialog { manager.dismissExisting() val args = Bundle().apply { putParcelable(Const.CANVAS_CONTEXT, canvasContext) @@ -174,7 +187,6 @@ class PeopleListFilterDialog : BaseCanvasAppCompatDialogFragment() { val dialog = PeopleListFilterDialog() dialog.canvasContextIdList = canvasContextIdList dialog.arguments = args - dialog.finishedCallback = callback return dialog } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/dialog/RadioButtonDialog.kt b/apps/teacher/src/main/java/com/instructure/teacher/dialog/RadioButtonDialog.kt index 35cc975b87..f113d473b0 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/dialog/RadioButtonDialog.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/dialog/RadioButtonDialog.kt @@ -56,6 +56,7 @@ class RadioButtonDialog : BaseCanvasAppCompatDialogFragment() { private var mOptions by SerializableListArg(emptyList(), Const.OPTIONS) private var mCallback by BlindSerializableArg() private var mTitle by StringArg(key = Const.TITLE) + private var mDisabledIndices by SerializableListArg(emptyList(), DISABLED_INDICES) private var currentSelectionIdx: Int = -1 @@ -92,6 +93,13 @@ class RadioButtonDialog : BaseCanvasAppCompatDialogFragment() { radioButton.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.dialogRadioButtonTextSize)) radioButton.id = index + 1 + // Disable the radio button if it's in the disabled indices list + val isDisabled = mDisabledIndices.contains(index) + radioButton.isEnabled = !isDisabled + if (isDisabled) { + radioButton.alpha = 0.5f + } + radioGroup.addView(radioButton) // The way this view has to be inflated and added means that layout measurements are skipped initially, @@ -134,9 +142,17 @@ class RadioButtonDialog : BaseCanvasAppCompatDialogFragment() { } companion object { + private const val DISABLED_INDICES = "disabledIndices" + + fun getInstance(manager: FragmentManager, title: String, options: ArrayList, + selectedIdx: Int, + callback: OnRadioButtonSelected): RadioButtonDialog { + return getInstance(manager, title, options, selectedIdx, emptyList(), callback) + } fun getInstance(manager: FragmentManager, title: String, options: ArrayList, selectedIdx: Int, + disabledIndices: List, callback: OnRadioButtonSelected): RadioButtonDialog { manager.dismissExisting() val dialog = RadioButtonDialog() @@ -144,6 +160,7 @@ class RadioButtonDialog : BaseCanvasAppCompatDialogFragment() { args.putString(Const.TITLE, title) args.putStringArrayList(Const.OPTIONS, options) args.putInt(Const.SELECTED_ITEM, selectedIdx) + args.putIntegerArrayList(DISABLED_INDICES, ArrayList(disabledIndices)) dialog.arguments = args dialog.mCallback = callback return dialog diff --git a/apps/teacher/src/main/java/com/instructure/teacher/factory/DashboardPresenterFactory.kt b/apps/teacher/src/main/java/com/instructure/teacher/factory/DashboardPresenterFactory.kt index 9f6f773622..f44d4755ca 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/factory/DashboardPresenterFactory.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/factory/DashboardPresenterFactory.kt @@ -17,10 +17,15 @@ package com.instructure.teacher.factory +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.teacher.presenters.DashboardPresenter import com.instructure.teacher.viewinterface.CoursesView import com.instructure.pandautils.blueprint.PresenterFactory -class DashboardPresenterFactory : PresenterFactory { - override fun create() = DashboardPresenter() +class DashboardPresenterFactory( + private val userApi: UserAPI.UsersInterface, + private val networkStateProvider: NetworkStateProvider +) : PresenterFactory { + override fun create() = DashboardPresenter(userApi, networkStateProvider) } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusFragment.kt index dd808b4416..14bcceeac3 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/features/syllabus/ui/SyllabusFragment.kt @@ -16,6 +16,7 @@ */ package com.instructure.teacher.features.syllabus.ui +import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import com.instructure.canvasapi2.models.Course @@ -54,6 +55,11 @@ abstract class SyllabusFragment : MobiusFragment) : PagerAdapter() { private fun isSyllabusPosition(position: Int) = position == SYLLABUS_TAB_POSITION private fun setupWebView(webView: CanvasWebView) { - val activity = (webView.context as? FragmentActivity) - activity?.let { webView.addVideoClient(it) } + val activity = webView.context.getFragmentActivity() + webView.addVideoClient(activity) webView.canvasWebViewClientCallback = object : CanvasWebView.CanvasWebViewClientCallback { override fun openMediaFromWebView(mime: String, url: String, filename: String) { RouteMatcher.openMedia(activity, url) @@ -83,7 +83,7 @@ class SyllabusTabAdapter(private val titles: List) : PagerAdapter() { } override fun launchInternalWebViewFragment(url: String) { - activity?.startActivity(InternalWebViewActivity.createIntent(webView.context, url, "", true)) + activity.startActivity(InternalWebViewActivity.createIntent(webView.context, url, "", 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 726612fd84..8c9b450ad6 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 @@ -76,6 +76,7 @@ class CourseSettingsFragment : BasePresenterFragment< override fun onReadySetGo(presenter: CourseSettingsFragmentPresenter) { setupToolbar() binding.courseImage.setCourseImage(course, course.color, !TeacherPrefs.hideCourseColorOverlay) + presenter.prefetchFrontPageStatus(course) } override fun onViewStateRestored(savedInstanceState: Bundle?) { @@ -94,7 +95,7 @@ class CourseSettingsFragment : BasePresenterFragment< } editCourseHomepage.root.onClickWithRequireNetwork { - presenter.editCourseHomePageClicked() + presenter.editCourseHomePageClicked(course) } } @@ -113,10 +114,28 @@ class CourseSettingsFragment : BasePresenterFragment< dialog.show(requireActivity().supportFragmentManager, EditCourseNameDialog::class.java.simpleName) } - override fun showEditCourseHomePageDialog() { + override fun showEditCourseHomePageDialog(hasFrontPage: Boolean) { val (keys, values) = mHomePages.toList().unzip() - val selectedIdx = keys.indexOf(course.homePage?.apiString) - val dialog = RadioButtonDialog.getInstance(requireActivity().supportFragmentManager, getString(R.string.set_home_to), values as ArrayList, selectedIdx) { idx -> + var selectedIdx = keys.indexOf(course.homePage?.apiString) + + val disabledIndices = if (!hasFrontPage) { + listOf(keys.indexOf("wiki")) + } else { + emptyList() + } + + // If the current selection is disabled, fall back to Course Activity Stream + if (disabledIndices.contains(selectedIdx)) { + selectedIdx = 0 // "feed" is at index 0 + } + + val dialog = RadioButtonDialog.getInstance( + requireActivity().supportFragmentManager, + getString(R.string.set_home_to), + values as ArrayList, + selectedIdx, + disabledIndices + ) { idx -> presenter.editCourseHomePage(keys[idx], course) } 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 b1e2c5db58..93f0a6e499 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 @@ -20,10 +20,15 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.view.MenuItem +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_CANCEL import android.view.View +import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView +import com.instructure.canvasapi2.apis.UserAPI import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.pageview.PageView @@ -33,7 +38,18 @@ import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.features.dashboard.edit.EditDashboardFragment import com.instructure.pandautils.features.dashboard.notifications.DashboardNotificationsFragment import com.instructure.pandautils.fragments.BaseSyncFragment -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.Const +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.fadeAnimationWithAction +import com.instructure.pandautils.utils.getDrawableCompat +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.teacher.R import com.instructure.teacher.activities.InitActivity import com.instructure.teacher.adapters.CoursesAdapter @@ -49,16 +65,26 @@ import com.instructure.teacher.utils.RecyclerViewUtils import com.instructure.teacher.utils.TeacherPrefs import com.instructure.teacher.utils.setupMenu import com.instructure.teacher.viewinterface.CoursesView +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import javax.inject.Inject private const val LIST_SPAN_COUNT = 1 @PageView @ScreenView(SCREEN_VIEW_DASHBOARD) +@AndroidEntryPoint class DashboardFragment : BaseSyncFragment(), CoursesView { + @Inject + lateinit var userApi: UserAPI.UsersInterface + + @Inject + lateinit var networkStateProvider: NetworkStateProvider + private val binding by viewBinding(FragmentDashboardBinding::bind) private lateinit var mGridLayoutManager: GridLayoutManager @@ -82,7 +108,7 @@ class DashboardFragment : BaseSyncFragment + val canvasContexts = result.getParcelableArrayList(PeopleListFilterDialog.RESULT_SELECTED_CONTEXTS) + canvasContexts?.let { + canvasContextsSelected = ArrayList(it) + presenter.canvasContextList = canvasContextsSelected as ArrayList + setupTitle(it) + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + canvasContextsSelected?.let { + outState.putParcelableArrayList(SELECTED_CONTEXTS_KEY, it) + } + } + override fun layoutResId(): Int = R.layout.fragment_people_list_layout override fun onCreateView(view: View) {} @@ -80,6 +102,10 @@ class PeopleListFragment : BaseSyncFragment - canvasContextsSelected = ArrayList() - canvasContextsSelected!!.addAll(canvasContexts) - - presenter.canvasContextList = canvasContextsSelected as ArrayList - setupTitle(canvasContexts) - }.show(requireActivity().supportFragmentManager, PeopleListFilterDialog::class.java.simpleName) + PeopleListFilterDialog.getInstance(childFragmentManager, presenter.canvasContextListIds, canvasContext, true) + .show(childFragmentManager, PeopleListFilterDialog::class.java.simpleName) false } clearFilterTextView.setOnClickListener { peopleFilter.setText(R.string.allPeople) + canvasContextsSelected = null presenter.clearCanvasContextList() clearFilterTextView.visibility = View.GONE } @@ -231,6 +252,8 @@ class PeopleListFragment : BaseSyncFragment() { + private var hasFrontPage: Boolean? = null + private var shouldShowDialogAfterFetch = false + override fun loadData(forceNetwork: Boolean) { // TODO: Load course data? } @@ -37,6 +42,27 @@ class CourseSettingsFragmentPresenter : FragmentPresenter() { + override fun onResponse(response: Response, linkHeaders: LinkHeaders, type: ApiType) { + hasFrontPage = response.isSuccessful && response.body() != null + // If no front page exists but course home is set to wiki, automatically set it to course activity stream + if (!hasFrontPage!! && course.homePage?.apiString == "wiki") { + editCourseHomePage("feed", course) + } + } + + override fun onFail(call: retrofit2.Call?, error: Throwable, response: Response<*>?) { + hasFrontPage = false + // If no front page exists but course home is set to wiki, automatically set it to course activity stream + if (course.homePage?.apiString == "wiki") { + editCourseHomePage("feed", course) + } + } + }) + } + fun editCourseName(newName: String, course: Course) { CourseManager.editCourseName(course.id, newName, mEditCourseNameCallback, true) } @@ -75,7 +101,34 @@ class CourseSettingsFragmentPresenter : FragmentPresenter() { + override fun onResponse(response: Response, linkHeaders: LinkHeaders, type: ApiType) { + hasFrontPage = response.isSuccessful && response.body() != null + if (shouldShowDialogAfterFetch) { + viewCallback?.showEditCourseHomePageDialog(hasFrontPage!!) + shouldShowDialogAfterFetch = false + } + } + + override fun onFail(call: retrofit2.Call?, error: Throwable, response: Response<*>?) { + // If the API call fails (e.g., 404 means no front page), cache as false + hasFrontPage = false + if (shouldShowDialogAfterFetch) { + viewCallback?.showEditCourseHomePageDialog(false) + shouldShowDialogAfterFetch = false + } + } } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/presenters/DashboardPresenter.kt b/apps/teacher/src/main/java/com/instructure/teacher/presenters/DashboardPresenter.kt index 07ae69e090..c8d1f21fae 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/presenters/DashboardPresenter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/presenters/DashboardPresenter.kt @@ -15,21 +15,31 @@ */ package com.instructure.teacher.presenters +import com.instructure.canvasapi2.CanvasRestAdapter +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.DashboardCard +import com.instructure.canvasapi2.models.DashboardPositions import com.instructure.canvasapi2.models.Tab import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.weave.apiAsync +import com.instructure.pandarecycler.util.toList import com.instructure.pandautils.blueprint.SyncPresenter import com.instructure.pandautils.utils.ColorApiHelper +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.teacher.viewinterface.CoursesView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -class DashboardPresenter : SyncPresenter(Course::class.java) { +class DashboardPresenter( + private val userApi: UserAPI.UsersInterface, + private val networkStateProvider: NetworkStateProvider +) : SyncPresenter(Course::class.java) { private var dashboardJob: Job? = null @@ -113,4 +123,36 @@ class DashboardPresenter : SyncPresenter(Course::class.java override fun areContentsTheSame(item1: Course, item2: Course): Boolean { return item1.contextId.hashCode() == item2.contextId.hashCode() } + + fun moveCourse(fromPosition: Int, toPosition: Int) { + if (fromPosition < 0 + || toPosition < 0 + || fromPosition >= data.size() + || toPosition >= data.size() + || fromPosition == toPosition + ) return + val courses = data.toList().toMutableList() + val movedCourse = courses.removeAt(fromPosition) + courses.add(toPosition, movedCourse) + data.clear() + data.addOrUpdate(courses) + } + + suspend fun saveDashboardPositions(): DataResult { + val courses = data.toList() + val positions = courses + .mapIndexed { index, course -> Pair(course.contextId, index) } + .toMap() + val dashboardPositions = DashboardPositions(positions) + + val result = userApi.updateDashboardPositions(dashboardPositions, RestParams(isForceReadFromNetwork = true)) + if (result is DataResult.Success) { + CanvasRestAdapter.clearCacheUrls("dashboard/dashboard_cards") + } + return result + } + + fun isOnline(): Boolean { + return networkStateProvider.isOnline() + } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/presenters/PeopleListPresenter.kt b/apps/teacher/src/main/java/com/instructure/teacher/presenters/PeopleListPresenter.kt index ae9bba1017..ce2f662301 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/presenters/PeopleListPresenter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/presenters/PeopleListPresenter.kt @@ -54,6 +54,7 @@ class PeopleListPresenter(private val mCanvasContext: CanvasContext?) : SyncPres filterCanvasContexts() } private val mUserList = ArrayList() + private var shouldApplyFilterAfterLoad = false private var mRun: RecipientRunnable? = null // If we try to automate this class the handler might create some issues. Cross that bridge when we come to it private val mHandler = Handler() @@ -78,6 +79,12 @@ class PeopleListPresenter(private val mCanvasContext: CanvasContext?) : SyncPres override fun onResponse(response: Response>, linkHeaders: LinkHeaders, type: ApiType) { data.addOrUpdate(response.body()!!) mUserList.addAll(response.body()!!) + + if (shouldApplyFilterAfterLoad && canvasContextList.isNotEmpty()) { + shouldApplyFilterAfterLoad = false + filterCanvasContexts() + } + viewCallback?.checkIfEmpty() viewCallback?.onRefreshFinished() } @@ -207,6 +214,19 @@ class PeopleListPresenter(private val mCanvasContext: CanvasContext?) : SyncPres refresh(false) } + fun restoreCanvasContextList(contexts: ArrayList) { + canvasContextList.clear() + canvasContextList.addAll(contexts) + shouldApplyFilterAfterLoad = true + + // Load group users for any group contexts + for (canvasContext in contexts) { + if (CanvasContext.Type.isGroup(canvasContext)) { + getGroupUsers(canvasContext) + } + } + } + private fun filterCanvasContexts() { clearData() diff --git a/apps/teacher/src/main/java/com/instructure/teacher/router/RouteMatcher.kt b/apps/teacher/src/main/java/com/instructure/teacher/router/RouteMatcher.kt index 8db4af399a..c113bb7fd6 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/router/RouteMatcher.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/router/RouteMatcher.kt @@ -21,6 +21,7 @@ import android.content.ActivityNotFoundException import android.content.Context import android.os.Bundle import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.loader.app.LoaderManager @@ -70,6 +71,7 @@ import com.instructure.pandautils.utils.LoaderUtils import com.instructure.pandautils.utils.RouteUtils import com.instructure.pandautils.utils.argsWithContext import com.instructure.pandautils.utils.nonNullArgs +import com.instructure.pandautils.views.CanvasLoadingView import com.instructure.teacher.PSPDFKit.AnnotationComments.AnnotationCommentListFragment import com.instructure.teacher.R import com.instructure.teacher.activities.BottomSheetActivity @@ -605,11 +607,28 @@ object RouteMatcher : BaseRouteMatcher() { private fun getLoaderCallbacks(activity: FragmentActivity): LoaderManager.LoaderCallbacks { if (openMediaCallbacks == null) { openMediaCallbacks = object : LoaderManager.LoaderCallbacks { + var dialog: AlertDialog? = null + override fun onCreateLoader(id: Int, args: Bundle?): Loader { + if (!activity.isFinishing) { + val view = android.view.LayoutInflater.from(activity).inflate(R.layout.dialog_loading_view, null) + val loadingView = view.findViewById(R.id.canvasLoadingView) + val teacherColor = androidx.core.content.ContextCompat.getColor(activity, R.color.login_teacherAppTheme) + loadingView?.setOverrideColor(teacherColor) + + dialog = AlertDialog.Builder(activity, R.style.CustomViewAlertDialog) + .setView(view) + .create() + dialog?.show() + } return OpenMediaAsyncTaskLoader(activity, args) } override fun onLoadFinished(loader: Loader, loadedMedia: OpenMediaAsyncTaskLoader.LoadedMedia) { + if (dialog == null || dialog?.isShowing == false) { + return // The user doesn't actually want to load the thing + } + dialog?.dismiss() try { if (loadedMedia.isError) { if (loadedMedia.errorType == OpenMediaAsyncTaskLoader.ErrorType.NO_APPS) { @@ -664,7 +683,9 @@ object RouteMatcher : BaseRouteMatcher() { openMediaBundle = null } - override fun onLoaderReset(loader: Loader) {} + override fun onLoaderReset(loader: Loader) { + dialog?.dismiss() + } } } return openMediaCallbacks!! diff --git a/apps/teacher/src/main/java/com/instructure/teacher/viewinterface/CourseSettingsFragmentView.kt b/apps/teacher/src/main/java/com/instructure/teacher/viewinterface/CourseSettingsFragmentView.kt index 94abd6dc38..510cd73fc8 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/viewinterface/CourseSettingsFragmentView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/viewinterface/CourseSettingsFragmentView.kt @@ -21,7 +21,7 @@ import com.instructure.pandautils.blueprint.FragmentViewInterface interface CourseSettingsFragmentView : FragmentViewInterface { fun showEditCourseNameDialog() - fun showEditCourseHomePageDialog() + fun showEditCourseHomePageDialog(hasFrontPage: Boolean) fun updateCourseName(course: Course) fun updateCourseHomePage(newHomePage: Course.HomePage?) } diff --git a/apps/teacher/src/main/res/values-ar/strings.xml b/apps/teacher/src/main/res/values-ar/strings.xml index b08a19f4dc..a51ad800eb 100644 --- a/apps/teacher/src/main/res/values-ar/strings.xml +++ b/apps/teacher/src/main/res/values-ar/strings.xml @@ -439,8 +439,8 @@ تم الإرسال متأخرًا لم يتم إرسالها بعد لم يتم تصحيحها - تم إحراز درجات أقل من… - تم إحراز درجات أكثر من… + أحرز درجة أقل من + أحرز درجة أكبر من تم إحراز درجات أقل من %s تم إحراز درجات أكثر من %s إضافة تعليق 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 432f227ea2..6b34f6c546 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 @@ -425,8 +425,8 @@ Afleveret sent Har ikke afleveret endnu Er ikke blevet vurderet - Fik mindre end … - Fik mere end … + Scorede mindre end + Scorede mere end Fik mindre end %s Fik mere end %s Tilføj kommentar 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 7891b0bbd3..8764ce3941 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 @@ -423,8 +423,8 @@ Submitted Late Haven\'t Submitted Yet Haven\'t Been Graded - Scored Less Than… - Scored More Than… + Scored Less than + Scored More than Scored Less Than %s Scored More Than %s Add Comment 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 5cdf9faef4..06fe0712e4 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 @@ -423,8 +423,8 @@ Submitted Late Haven\'t Submitted Yet Haven\'t Been Graded - Scored less than… - Scored more than… + Scored Less than + Scored More than Scored less than %s Scored more than %s Add Comment 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 2944fb7df8..813790e8ca 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 @@ -425,8 +425,8 @@ Levert etter fristen Ikke sendt inn ennå Ikke vurdert - Scoret mindre enn… - Scoret mer enn… + Fikk resultat lavere enn + Fikk resultat høyere enn Dårligere vurdering enn %s Bedre vurdering enn %s Legg til kommentar 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 c855aeb1c0..70bd3aa391 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 @@ -425,8 +425,8 @@ Sent inskickad Inte har skickat in ännu Har inte blivit bedömd - Fick sämre resultat än… - Fick bättre resultat än… + Fick mindre än + Fick mer än Fick sämre resultat än %s Fick bättre resultat än %s Lägg till kommentar 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 cc0dbca868..ec68181f05 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 @@ -419,8 +419,8 @@ 逾期提交 尚未提交 尚未評分 - 得分低於… - 得分高於… + 得分低於 + 得分高於 得分低於 %s 得分高於 %s 添加評論 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 1c8e7cd8d2..934a615f29 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 @@ -419,8 +419,8 @@ 迟交 暂未提交 暂未评分 - 分数低于… - 分数高于… + 分数低于 + 分数高于 分数低于%s 分数高于%s 添加评论 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 cc0dbca868..ec68181f05 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 @@ -419,8 +419,8 @@ 逾期提交 尚未提交 尚未評分 - 得分低於… - 得分高於… + 得分低於 + 得分高於 得分低於 %s 得分高於 %s 添加評論 diff --git a/apps/teacher/src/main/res/values-ca/strings.xml b/apps/teacher/src/main/res/values-ca/strings.xml index 4da720d74c..6724c41d5a 100644 --- a/apps/teacher/src/main/res/values-ca/strings.xml +++ b/apps/teacher/src/main/res/values-ca/strings.xml @@ -425,8 +425,8 @@ S\'ha entregat tard Encara no s\'ha entregat Encara no s\'ha qualificat - La puntuació és de menys de… - La puntuació és de més de… + La puntuació ha estat inferior a + La puntuació ha estat superior a La puntuació és de menys de %s La puntuació és de més de %s Afegeix un comentari diff --git a/apps/teacher/src/main/res/values-cy/strings.xml b/apps/teacher/src/main/res/values-cy/strings.xml index b607e909fd..e52cc88323 100644 --- a/apps/teacher/src/main/res/values-cy/strings.xml +++ b/apps/teacher/src/main/res/values-cy/strings.xml @@ -423,8 +423,8 @@ Wedi’i gyflwyno’n hwyr Heb gyflwyno eto Heb gael gradd - Wedi cael sgôr o lai na … - Wedi cael sgôr o fwy na … + Wedi cael sgôr sy\'n Llai na + Wedi cael sgôr sy\'n Fwy na Wedi cael sgôr o lai na %s Wedi cael sgôr o fwy na %s Ychwanegu Sylw diff --git a/apps/teacher/src/main/res/values-da/strings.xml b/apps/teacher/src/main/res/values-da/strings.xml index 51c617e8f7..d89f254ecb 100644 --- a/apps/teacher/src/main/res/values-da/strings.xml +++ b/apps/teacher/src/main/res/values-da/strings.xml @@ -423,8 +423,8 @@ Afleveret sent Jeg har ikke afleveret endnu Jeg har ikke fået karakter - Fik mindre end … - Fik mere end … + Scorede mindre end + Scorede mere end Fik mindre end %s Fik mere end %s Tilføj kommentar diff --git a/apps/teacher/src/main/res/values-de/strings.xml b/apps/teacher/src/main/res/values-de/strings.xml index e85390702c..20cc4cc6d9 100644 --- a/apps/teacher/src/main/res/values-de/strings.xml +++ b/apps/teacher/src/main/res/values-de/strings.xml @@ -423,8 +423,8 @@ Verspätet abgegeben Haben noch nicht abgegeben Wurden nicht benotet - Punktzahl unter … - Punktzahl über … + Punktzahl weniger als + Punktzahl mehr als Punktzahl unter %s Punktzahl über %s Kommentar hinzufügen 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 89a7b0c1d5..62a4f98001 100644 --- a/apps/teacher/src/main/res/values-en-rAU/strings.xml +++ b/apps/teacher/src/main/res/values-en-rAU/strings.xml @@ -423,8 +423,8 @@ Submitted Late Haven\'t Submitted Yet Haven\'t Been Marked - Scored Less Than… - Scored More Than… + Scored Less than + Scored More than Scored Less Than %s Scored More Than %s Add Comment 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 fa043462b1..132287df5b 100644 --- a/apps/teacher/src/main/res/values-en-rCA/strings.xml +++ b/apps/teacher/src/main/res/values-en-rCA/strings.xml @@ -426,8 +426,8 @@ Submitted Late Haven\'t Submitted Yet Haven\'t Been Graded - Scored Less Than… - Scored More Than… + Scored Less than + Scored More than Scored Less Than %s Scored More Than %s Add Comment 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 5cdf9faef4..06fe0712e4 100644 --- a/apps/teacher/src/main/res/values-en-rCY/strings.xml +++ b/apps/teacher/src/main/res/values-en-rCY/strings.xml @@ -423,8 +423,8 @@ Submitted Late Haven\'t Submitted Yet Haven\'t Been Graded - Scored less than… - Scored more than… + Scored Less than + Scored More than Scored less than %s Scored more than %s Add Comment 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 95a5328d3e..1e2d03c067 100644 --- a/apps/teacher/src/main/res/values-en-rGB/strings.xml +++ b/apps/teacher/src/main/res/values-en-rGB/strings.xml @@ -423,8 +423,8 @@ Submitted Late Haven\'t Submitted Yet Haven\'t Been Graded - Scored less than… - Scored more than… + Scored Less than + Scored More than Scored less than %s Scored more than %s Add Comment diff --git a/apps/teacher/src/main/res/values-en/strings.xml b/apps/teacher/src/main/res/values-en/strings.xml index 249906a3c9..ae9fe72034 100644 --- a/apps/teacher/src/main/res/values-en/strings.xml +++ b/apps/teacher/src/main/res/values-en/strings.xml @@ -426,8 +426,8 @@ Submitted Late Haven\'t Submitted Yet Haven\'t Been Graded - Scored Less Than… - Scored More Than… + Scored Less than + Scored More than Scored Less Than %s Scored More Than %s Add Comment 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 958ad85c34..7980014f82 100644 --- a/apps/teacher/src/main/res/values-es-rES/strings.xml +++ b/apps/teacher/src/main/res/values-es-rES/strings.xml @@ -425,8 +425,8 @@ Entregado con retraso Aún no has entregado No has sido evaluado - Has obtenido una puntuación inferior a… - Has obtenido una puntuación superior a… + Puntuación inferior a + Puntuación superior a Han obtenido una puntuación inferior a %s Han obtenido una puntuación superior a %s Añadir comentario diff --git a/apps/teacher/src/main/res/values-es/strings.xml b/apps/teacher/src/main/res/values-es/strings.xml index cc38b0e8a4..f75c1bb81b 100644 --- a/apps/teacher/src/main/res/values-es/strings.xml +++ b/apps/teacher/src/main/res/values-es/strings.xml @@ -424,8 +424,8 @@ Entregado con atraso Aún no ha entregado No ha sido calificada - Obtuvieron menos de… - Obtuvieron más de… + Obtuvo menos de + Obtuvo más de Obtuvo un puntaje inferior a %s Obtuvo un puntaje superior a %s Agregar comentario diff --git a/apps/teacher/src/main/res/values-fi/strings.xml b/apps/teacher/src/main/res/values-fi/strings.xml index 52b157a3e9..f3e32ddb8e 100644 --- a/apps/teacher/src/main/res/values-fi/strings.xml +++ b/apps/teacher/src/main/res/values-fi/strings.xml @@ -423,8 +423,8 @@ Lähetetty myöhässä Ei ole lähettänyt vielä Ei ole vielä arvosteltu - Sai vähemmän pisteitä kuin… - Sai enemmän pisteitä kuin… + Sai vähemmän pisteitä kuin + Sai enemmän pisteitä kuin Sai vähemmän pisteitä kuin%s Sai enemmän pisteitä kuin%s Lisää kommentti diff --git a/apps/teacher/src/main/res/values-fr-rCA/strings.xml b/apps/teacher/src/main/res/values-fr-rCA/strings.xml index 15e6bb4cbf..73ce00652c 100644 --- a/apps/teacher/src/main/res/values-fr-rCA/strings.xml +++ b/apps/teacher/src/main/res/values-fr-rCA/strings.xml @@ -423,8 +423,8 @@ Envoyé tardivement Pas encore envoyé N’ont pas été notés - Noté moins que… - Noté plus que… + Moins de + Plus de Noté moins que %s Noté plus que %s Ajouter un commentaire diff --git a/apps/teacher/src/main/res/values-fr/strings.xml b/apps/teacher/src/main/res/values-fr/strings.xml index 4c25317415..9f776238be 100644 --- a/apps/teacher/src/main/res/values-fr/strings.xml +++ b/apps/teacher/src/main/res/values-fr/strings.xml @@ -423,8 +423,8 @@ Envoyé tard N\'ont pas encore soumis N\'ont pas encore été notés - A obtenu une note inférieure à… - A obtenu une note supérieure à… + A obtenu moins de + A obtenu plus de A obtenu une note inférieure à %s A obtenu une note supérieure à %s Ajouter un commentaire diff --git a/apps/teacher/src/main/res/values-ga/strings.xml b/apps/teacher/src/main/res/values-ga/strings.xml index ff543b82d7..0c4b931bba 100644 --- a/apps/teacher/src/main/res/values-ga/strings.xml +++ b/apps/teacher/src/main/res/values-ga/strings.xml @@ -425,8 +425,8 @@ Curtha isteach Déanach Nach bhfuil curtha isteach fós Nach bhfuil Marcáilte - Scóráil Níos lú ná… - Scóráil Níos Mó ná… + Scóráladh Níos Lú ná + Scóráladh Níos Mó ná Scóráil Níos lú ná %s Scóráil Níos Mó ná %s Cuir Trácht leis diff --git a/apps/teacher/src/main/res/values-hi/strings.xml b/apps/teacher/src/main/res/values-hi/strings.xml index c723fbe296..21761edd75 100644 --- a/apps/teacher/src/main/res/values-hi/strings.xml +++ b/apps/teacher/src/main/res/values-hi/strings.xml @@ -425,8 +425,8 @@ विलंब से सबमिट किया गया अभी तक सबमिट नहीं किया गया है अभी तक ग्रेड नहीं किया गया है - … से कम अंक प्राप्त हुए - … से अधिक अंक प्राप्त हुए + इस से कम अंक मिले: + इस से ज़्यादा अंक मिले: %s से कम अंक प्राप्त हुए %s से अधिक अंक प्राप्त हुए टिप्पणी जोड़ें diff --git a/apps/teacher/src/main/res/values-ht/strings.xml b/apps/teacher/src/main/res/values-ht/strings.xml index 75a5e971a9..6d9ab85cfc 100644 --- a/apps/teacher/src/main/res/values-ht/strings.xml +++ b/apps/teacher/src/main/res/values-ht/strings.xml @@ -423,8 +423,8 @@ Soumèt an Reta Poko Soumèt Poko Klase - Fè Mwens Ke… - Fè Plis ke… + Fè Mwens Pase + Fè Plis Pase Fè Mwens Ke %s Fè Plis ke %s Ajoute Kòmantè diff --git a/apps/teacher/src/main/res/values-id/strings.xml b/apps/teacher/src/main/res/values-id/strings.xml index a969cbb705..8160cd9934 100644 --- a/apps/teacher/src/main/res/values-id/strings.xml +++ b/apps/teacher/src/main/res/values-id/strings.xml @@ -425,8 +425,8 @@ Diserahkan Terlambat Belum Diserahkan Belum Dinilai - Mendapat Skor Kurang Dari … - Mendapat Skor Lebih Dari … + Nilai Kurang dari + Nilai Lebih dari Mendapat Skor Kurang Dari %s Mendapat Skor Lebih Dari %s Tambah Komentar diff --git a/apps/teacher/src/main/res/values-is/strings.xml b/apps/teacher/src/main/res/values-is/strings.xml index f7033f7b0a..f544fc1310 100644 --- a/apps/teacher/src/main/res/values-is/strings.xml +++ b/apps/teacher/src/main/res/values-is/strings.xml @@ -424,8 +424,8 @@ Skilað seint Hafa ekki skilað enn Hafa ekki verið metin - Fékk lægri einkunn en… - Fékk hærri einkunn en… + Lægri einkunn en + Hærri einkunn en Fékk lægri einkunn en %s Fékk hærri einkunn en %s Bæta við athugasemd diff --git a/apps/teacher/src/main/res/values-it/strings.xml b/apps/teacher/src/main/res/values-it/strings.xml index c8968e6721..7834756330 100644 --- a/apps/teacher/src/main/res/values-it/strings.xml +++ b/apps/teacher/src/main/res/values-it/strings.xml @@ -424,8 +424,8 @@ Inviato in ritardo Non è stato ancora consegnato Non è stato valutato - Punteggio ottenuto inferiore a… - Punteggio ottenuto superiore a… + Punteggio ottenuto inferiore a + Punteggio ottenuto superiore a Punteggio ottenuto inferiore a %s Punteggio ottenuto superiore a %s Aggiungi commento diff --git a/apps/teacher/src/main/res/values-ja/strings.xml b/apps/teacher/src/main/res/values-ja/strings.xml index 90c554aede..4057dce649 100644 --- a/apps/teacher/src/main/res/values-ja/strings.xml +++ b/apps/teacher/src/main/res/values-ja/strings.xml @@ -192,7 +192,7 @@ ポイント ポイント 期日なし - 期日 + 提出期限 返信を表示 ディスカッションを表示 @@ -419,8 +419,8 @@ 遅れた提出 まだ提出されていません まだ採点されていません - … 未満のスコア - …を上回るスコア + ...未満のスコア + ...を上回るスコア %s 未満のスコア %s を上回るスコア コメントを追加 diff --git a/apps/teacher/src/main/res/values-mi/strings.xml b/apps/teacher/src/main/res/values-mi/strings.xml index b03351a50b..46c421575d 100644 --- a/apps/teacher/src/main/res/values-mi/strings.xml +++ b/apps/teacher/src/main/res/values-mi/strings.xml @@ -423,8 +423,8 @@ Tukunga tūreiti Kaore anō i tukunga noa Kaore anō i kōekehia noa - Kaute iti ake i … - Kaute nui atu i… + He iti iho te kaute + I piro Neke atu i te Kaute iti ake i %s Kaute nui atu i %s Tāpiri Tākupu diff --git a/apps/teacher/src/main/res/values-ms/strings.xml b/apps/teacher/src/main/res/values-ms/strings.xml index e8b6e183d8..aed553386a 100644 --- a/apps/teacher/src/main/res/values-ms/strings.xml +++ b/apps/teacher/src/main/res/values-ms/strings.xml @@ -425,8 +425,8 @@ Diserahkan Lewat Belum Diserahkan Belum Digredkan - Mendapat Skor Kurang Daripada… - Mendapat Skor Lebih Daripada… + Mendapat Skor Kurang daripada + Mendapat Skor Lebih daripada Mendapat Skor Kurang Daripada %s Mendapat Skor Lebih Daripada %s Tambah Komen diff --git a/apps/teacher/src/main/res/values-nb/strings.xml b/apps/teacher/src/main/res/values-nb/strings.xml index c929da1b54..09addaaaba 100644 --- a/apps/teacher/src/main/res/values-nb/strings.xml +++ b/apps/teacher/src/main/res/values-nb/strings.xml @@ -425,8 +425,8 @@ Levert etter fristen Ikke sendt inn ennå Ikke vurdert - Scoret mindre enn… - Scoret mer enn… + Fikk resultat lavere enn + Fikk resultat høyere enn Dårligere vurdering enn %s Bedre vurdering enn %s Legg til kommentar diff --git a/apps/teacher/src/main/res/values-nl/strings.xml b/apps/teacher/src/main/res/values-nl/strings.xml index 91e491941a..612c064b7b 100644 --- a/apps/teacher/src/main/res/values-nl/strings.xml +++ b/apps/teacher/src/main/res/values-nl/strings.xml @@ -423,8 +423,8 @@ Laat ingezonden Zijn nog niet ingezonden Zijn niet beoordeeld - Lager gescoord dan… - Hoger gescoord dan… + Lager gescoord dan + Hoger gescoord dan Lager gescoord dan %s Hoger gescoord dan %s Opmerking toevoegen diff --git a/apps/teacher/src/main/res/values-pl/strings.xml b/apps/teacher/src/main/res/values-pl/strings.xml index 964bb741d2..eaa231cfd2 100644 --- a/apps/teacher/src/main/res/values-pl/strings.xml +++ b/apps/teacher/src/main/res/values-pl/strings.xml @@ -431,8 +431,8 @@ Przesłano z opóźnieniem Nie przesłano Nie oceniono - Ocenione poniżej… - Ocenione powyżej… + Wynik punktowy poniżej + Wynik punktowy powyżej Ocenione poniżej %s Ocenione powyżej %s Dodaj komentarz 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 434de561a9..bc677ae43d 100644 --- a/apps/teacher/src/main/res/values-pt-rBR/strings.xml +++ b/apps/teacher/src/main/res/values-pt-rBR/strings.xml @@ -424,8 +424,8 @@ Enviado atrasado Ainda não enviou Não foi avaliado - Pontuou menos que … - Pontuou mais que … + Pontuou menos que + Pontuou mais que Pontuou menos do que %s Pontuou mais do que %s Adicionar comentário 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 1567cdf2a8..eae07b0fe4 100644 --- a/apps/teacher/src/main/res/values-pt-rPT/strings.xml +++ b/apps/teacher/src/main/res/values-pt-rPT/strings.xml @@ -423,8 +423,8 @@ Submetido tardiamente Ainda não enviou Não foram avaliados - Pontuação Menor que… - Pontuação Maior que… + Pontuação inferior a + Pontuação superior a Pontuação Menor que %s Pontuação Maior que %s Adicionar comentário diff --git a/apps/teacher/src/main/res/values-ru/strings.xml b/apps/teacher/src/main/res/values-ru/strings.xml index a5fdfe999a..a766fefe94 100644 --- a/apps/teacher/src/main/res/values-ru/strings.xml +++ b/apps/teacher/src/main/res/values-ru/strings.xml @@ -431,8 +431,8 @@ Отправлено поздно Еще не отправлено Оценка еще не поставлена - Оценка ниже… - Оценка выше… + Полученная оценка ниже + Полученная оценка выше Оценка ниже %s Оценка выше %s Добавить комментарий diff --git a/apps/teacher/src/main/res/values-sl/strings.xml b/apps/teacher/src/main/res/values-sl/strings.xml index 0034443141..ec0023df01 100644 --- a/apps/teacher/src/main/res/values-sl/strings.xml +++ b/apps/teacher/src/main/res/values-sl/strings.xml @@ -423,8 +423,8 @@ Poslano z zamudo Še niso oddali. Še ni ocenjeno. - Dosegel rezultat, manjši od… - Dosegel rezultat, večji od… + Dosegel rezultat, manjši od + Dosegel rezultat, večji od Dosegel rezultat, manjši od %s Dosegel rezultat, večji od %s Dodaj komentar diff --git a/apps/teacher/src/main/res/values-sv/strings.xml b/apps/teacher/src/main/res/values-sv/strings.xml index 3c38625b90..71c762669d 100644 --- a/apps/teacher/src/main/res/values-sv/strings.xml +++ b/apps/teacher/src/main/res/values-sv/strings.xml @@ -424,8 +424,8 @@ Sent inskickad Inte har skickat in ännu Har inte blivit bedömd - Fick sämre resultat än… - Fick bättre resultat än… + Fick mindre än + Fick mer än Fick sämre resultat än %s Fick bättre resultat än %s Lägg till kommentar diff --git a/apps/teacher/src/main/res/values-th/strings.xml b/apps/teacher/src/main/res/values-th/strings.xml index f2d532e637..46b3dcf62c 100644 --- a/apps/teacher/src/main/res/values-th/strings.xml +++ b/apps/teacher/src/main/res/values-th/strings.xml @@ -425,8 +425,8 @@ จัดส่งล่าช้า ยังไม่ได้จัดส่ง ยังไม่ได้ให้เกรด - ได้คะแนนต่ำกว่า… - ได้คะแนนมากกว่า… + ทำคะแนนได้น้อยกว่า + ทำคะแนนได้มากกว่า ได้คะแนนน้อยกว่า %s ได้คะแนนมากกว่า %s เพิ่มความเห็น diff --git a/apps/teacher/src/main/res/values-vi/strings.xml b/apps/teacher/src/main/res/values-vi/strings.xml index 7e23eea6e6..2fa202b268 100644 --- a/apps/teacher/src/main/res/values-vi/strings.xml +++ b/apps/teacher/src/main/res/values-vi/strings.xml @@ -425,8 +425,8 @@ Nộp Trễ Chưa Nộp Chưa Chấm Điểm - Đạt Mức Điểm Dưới… - Đạt Mức Điểm Trên… + Đạt Điểm Dưới + Đạt Điểm Trên Đạt Mức Điểm Dưới%s Đạt Mức Điểm Trên%s Thêm Bình Luận diff --git a/apps/teacher/src/main/res/values-zh/strings.xml b/apps/teacher/src/main/res/values-zh/strings.xml index 1c8e7cd8d2..934a615f29 100644 --- a/apps/teacher/src/main/res/values-zh/strings.xml +++ b/apps/teacher/src/main/res/values-zh/strings.xml @@ -419,8 +419,8 @@ 迟交 暂未提交 暂未评分 - 分数低于… - 分数高于… + 分数低于 + 分数高于 分数低于%s 分数高于%s 添加评论 diff --git a/apps/teacher/src/test/java/com/instructure/teacher/features/coursesettings/CourseSettingsFragmentPresenterTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/features/coursesettings/CourseSettingsFragmentPresenterTest.kt new file mode 100644 index 0000000000..486ed73c77 --- /dev/null +++ b/apps/teacher/src/test/java/com/instructure/teacher/features/coursesettings/CourseSettingsFragmentPresenterTest.kt @@ -0,0 +1,141 @@ +package com.instructure.teacher.features.coursesettings + +import com.instructure.canvasapi2.StatusCallback +import com.instructure.canvasapi2.managers.PageManager +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Page +import com.instructure.canvasapi2.utils.ApiType +import com.instructure.canvasapi2.utils.LinkHeaders +import com.instructure.teacher.presenters.CourseSettingsFragmentPresenter +import com.instructure.teacher.viewinterface.CourseSettingsFragmentView +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +class CourseSettingsFragmentPresenterTest { + + private lateinit var presenter: CourseSettingsFragmentPresenter + private lateinit var view: CourseSettingsFragmentView + private lateinit var course: Course + + @Before + fun setUp() { + presenter = CourseSettingsFragmentPresenter() + view = mockk(relaxed = true) + course = Course(id = 123L, name = "Test Course") + presenter.onViewAttached(view) + + mockkObject(PageManager) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `editCourseHomePageClicked should call getFrontPage with course`() { + // Given + every { PageManager.getFrontPage(any(), any(), any()) } just Runs + + // When + presenter.editCourseHomePageClicked(course) + + // Then + verify { PageManager.getFrontPage(course, true, any()) } + } + + @Test + fun `editCourseHomePageClicked should show dialog with hasFrontPage true when API returns success with page`() { + // Given + val page = Page(id = 1L, title = "Front Page") + val response = mockk>(relaxed = true) { + every { isSuccessful } returns true + every { body() } returns page + } + + val callbackSlot = slot>() + every { PageManager.getFrontPage(any(), any(), capture(callbackSlot)) } answers { + callbackSlot.captured.onResponse(response, LinkHeaders(), ApiType.API) + } + + // When + presenter.editCourseHomePageClicked(course) + + // Then + verify { view.showEditCourseHomePageDialog(true) } + } + + @Test + fun `editCourseHomePageClicked should show dialog with hasFrontPage false when API returns success but null body`() { + // Given + val response = mockk>(relaxed = true) { + every { isSuccessful } returns true + every { body() } returns null + } + + val callbackSlot = slot>() + every { PageManager.getFrontPage(any(), any(), capture(callbackSlot)) } answers { + callbackSlot.captured.onResponse(response, LinkHeaders(), ApiType.API) + } + + // When + presenter.editCourseHomePageClicked(course) + + // Then + verify { view.showEditCourseHomePageDialog(false) } + } + + @Test + fun `editCourseHomePageClicked should show dialog with hasFrontPage false when API returns error response`() { + // Given + val response = mockk>(relaxed = true) { + every { isSuccessful } returns false + every { body() } returns null + } + + val callbackSlot = slot>() + every { PageManager.getFrontPage(any(), any(), capture(callbackSlot)) } answers { + callbackSlot.captured.onResponse(response, LinkHeaders(), ApiType.API) + } + + // When + presenter.editCourseHomePageClicked(course) + + // Then + verify { view.showEditCourseHomePageDialog(false) } + } + + @Test + fun `editCourseHomePageClicked should show dialog with hasFrontPage false when API call fails`() { + // Given + val callbackSlot = slot>() + every { PageManager.getFrontPage(any(), any(), capture(callbackSlot)) } answers { + callbackSlot.captured.onFail(null, Throwable("404 Not Found"), null) + } + + // When + presenter.editCourseHomePageClicked(course) + + // Then + verify { view.showEditCourseHomePageDialog(false) } + } + + @Test + fun `editCourseNameClicked should call showEditCourseNameDialog`() { + // When + presenter.editCourseNameClicked() + + // Then + verify { view.showEditCourseNameDialog() } + } +} \ No newline at end of file diff --git a/automation/dataseedingapi/build.gradle b/automation/dataseedingapi/build.gradle index 4828823e11..b432f9aa54 100644 --- a/automation/dataseedingapi/build.gradle +++ b/automation/dataseedingapi/build.gradle @@ -12,6 +12,7 @@ buildscript { dependencies { classpath Plugins.KOTLIN + classpath Plugins.APOLLO classpath Libs.APOLLO_RUNTIME } } diff --git a/automation/dataseedingapi/src/main/graphql/com/instructure/dataseeding/DeleteCustomGradeStatusMutation.graphql b/automation/dataseedingapi/src/main/graphql/com/instructure/dataseeding/DeleteCustomGradeStatusMutation.graphql new file mode 100644 index 0000000000..771bd31450 --- /dev/null +++ b/automation/dataseedingapi/src/main/graphql/com/instructure/dataseeding/DeleteCustomGradeStatusMutation.graphql @@ -0,0 +1,25 @@ +# +# Copyright (C) 2025 - 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. +# + +mutation deleteCustomGradeStatus($id: ID!) { + deleteCustomGradeStatus(input: {id: $id}) { + customGradeStatusId + errors { + attribute + message + } + } +} \ No newline at end of file diff --git a/automation/dataseedingapi/src/main/graphql/com/instructure/dataseeding/UpsertCustomGradeStatusMutation.graphql b/automation/dataseedingapi/src/main/graphql/com/instructure/dataseeding/UpsertCustomGradeStatusMutation.graphql new file mode 100644 index 0000000000..2b6fb47dcd --- /dev/null +++ b/automation/dataseedingapi/src/main/graphql/com/instructure/dataseeding/UpsertCustomGradeStatusMutation.graphql @@ -0,0 +1,29 @@ +# +# Copyright (C) 2025 - 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. +# + +mutation upsertCustomGradeStatus($id: ID, $color: String!, $name: String!) { + upsertCustomGradeStatus(input: {id: $id, color: $color, name: $name}) { + customGradeStatus { + id: _id + name + color + } + errors { + attribute + message + } + } +} \ No newline at end of file diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/CustomStatusApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/CustomStatusApi.kt new file mode 100644 index 0000000000..94be4cbd0a --- /dev/null +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/CustomStatusApi.kt @@ -0,0 +1,67 @@ +// +// Copyright (C) 2025-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.dataseeding.api + +import com.apollographql.apollo.api.Optional +import com.instructure.dataseeding.util.CanvasNetworkAdapter +import com.instructure.dataseedingapi.DeleteCustomGradeStatusMutation +import com.instructure.dataseedingapi.UpsertCustomGradeStatusMutation +import kotlinx.coroutines.runBlocking + +object CustomStatusApi { + + fun upsertCustomGradeStatus( + token: String, + name: String, + color: String, + id: String? = null + ): String? { + val apolloClient = CanvasNetworkAdapter.getApolloClient(token) + + val mutationCall = UpsertCustomGradeStatusMutation( + id = Optional.presentIfNotNull(id), + color = color, + name = name + ) + + return runBlocking { + val response = apolloClient.mutation(mutationCall).executeV3() + response.data?.upsertCustomGradeStatus?.customGradeStatus?.id + } + } + + fun deleteCustomGradeStatus(token: String, id: String) { + val apolloClient = CanvasNetworkAdapter.getApolloClient(token) + + val mutationCall = DeleteCustomGradeStatusMutation(id = id) + + runBlocking { + val response = apolloClient.mutation(mutationCall).executeV3() + + if (response.hasErrors()) { + val errorMessages = response.errors?.joinToString(", ") { it.message } + throw IllegalStateException("Failed to delete custom grade status '$id': $errorMessages") + } + + val errors = response.data?.deleteCustomGradeStatus?.errors + if (!errors.isNullOrEmpty()) { + val errorMessages = errors.joinToString(", ") { "${it.attribute}: ${it.message}" } + throw IllegalStateException("Failed to delete custom grade status '$id': $errorMessages") + } + } + } +} \ No newline at end of file diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt index 4f11a7a1e7..67066c6ab8 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SubmissionsApi.kt @@ -17,12 +17,24 @@ package com.instructure.dataseeding.api -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.model.AssignmentApiModel +import com.instructure.dataseeding.model.AttachmentApiModel +import com.instructure.dataseeding.model.CreateSubmissionCommentWrapper +import com.instructure.dataseeding.model.FileType +import com.instructure.dataseeding.model.GradeSubmission +import com.instructure.dataseeding.model.GradeSubmissionWrapper +import com.instructure.dataseeding.model.SubmissionApiModel +import com.instructure.dataseeding.model.SubmissionType +import com.instructure.dataseeding.model.SubmitCourseAssignmentSubmissionWrapper import com.instructure.dataseeding.util.CanvasNetworkAdapter import com.instructure.dataseeding.util.Randomizer import com.instructure.dataseeding.util.RetryBackoff import retrofit2.Call -import retrofit2.http.* +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path object SubmissionsApi { interface SubmissionsService { @@ -103,10 +115,11 @@ object SubmissionsApi { assignmentId: Long, studentId: Long, excused: Boolean = false, - postedGrade: String? = null): SubmissionApiModel { + postedGrade: String? = null, + customGradeStatusId: String? = null): SubmissionApiModel { return submissionsService(teacherToken) - .gradeSubmission(courseId, assignmentId, studentId, GradeSubmissionWrapper(GradeSubmission(postedGrade, excused))) + .gradeSubmission(courseId, assignmentId, studentId, GradeSubmissionWrapper(GradeSubmission(postedGrade, excused, customGradeStatusId))) .execute() .body()!! } diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionApiModel.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionApiModel.kt index 5436d6c8ec..ee2272ad31 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionApiModel.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/SubmissionApiModel.kt @@ -78,7 +78,10 @@ data class GradeSubmission( val grade: String? = null, @SerializedName("excuse") - val excused: Boolean + val excused: Boolean, + + @SerializedName("custom_grade_status_id") + val customGradeStatusId: String? = null ) data class GradeSubmissionWrapper( diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt index 106fb425bc..6508504a3c 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/TestMetaData.kt @@ -34,12 +34,12 @@ enum class FeatureCategory { ASSIGNMENTS, SUBMISSIONS, LOGIN, COURSE, DASHBOARD, GROUPS, SETTINGS, PAGES, DISCUSSIONS, MODULES, CALENDAR, INBOX, GRADES, FILES, EVENTS, PEOPLE, CONFERENCES, COLLABORATIONS, SYLLABUS, TODOS, QUIZZES, NOTIFICATIONS, ANNOTATIONS, ANNOUNCEMENTS, COMMENTS, BOOKMARKS, NONE, CANVAS_FOR_ELEMENTARY, SPEED_GRADER, SYNC_SETTINGS, SYNC_PROGRESS, OFFLINE_CONTENT, LEFT_SIDE_MENU, - COURSE_LIST, MANAGE_STUDENTS, COURSE_BROWSER, ALERTS, ACCOUNT_CREATION, COURSE_DETAILS + COURSE_LIST, MANAGE_STUDENTS, COURSE_BROWSER, ALERTS, ACCOUNT_CREATION, COURSE_DETAILS, CUSTOM_STATUSES } enum class SecondaryFeatureCategory { NONE, LOGIN_K5, - SUBMISSIONS_TEXT_ENTRY, SUBMISSIONS_ANNOTATIONS, SUBMISSIONS_ONLINE_URL, SUBMISSIONS_MULTIPLE_TYPE, + SUBMISSIONS_TEXT_ENTRY, SUBMISSIONS_ANNOTATIONS, SUBMISSIONS_ONLINE_URL, SUBMISSIONS_MULTIPLE_TYPE, SUBMISSIONS_FILE_UPLOAD, SUBMISSIONS_MEDIA_RECORDING, ASSIGNMENT_COMMENTS, ASSIGNMENT_QUIZZES, ASSIGNMENT_DISCUSSIONS, HOMEROOM, K5_GRADES, IMPORTANT_DATES, RESOURCES, SCHEDULE, GROUPS_DASHBOARD, GROUPS_FILES, GROUPS_ANNOUNCEMENTS, GROUPS_DISCUSSIONS, GROUPS_PAGES, GROUPS_PEOPLE, EVENTS_DISCUSSIONS, EVENTS_QUIZZES, EVENTS_ASSIGNMENTS, EVENTS_NOTIFICATIONS, SETTINGS_EMAIL_NOTIFICATIONS, diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/interaction/ToDoListInteractionTest.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/interaction/ToDoListInteractionTest.kt new file mode 100644 index 0000000000..d1d357511b --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/interaction/ToDoListInteractionTest.kt @@ -0,0 +1,627 @@ +/* + * Copyright (C) 2025 - 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.canvas.espresso.common.interaction + +import com.instructure.canvas.espresso.CanvasComposeTest +import com.instructure.canvas.espresso.common.pages.compose.CalendarEventDetailsPage +import com.instructure.canvas.espresso.common.pages.compose.CalendarScreenPage +import com.instructure.canvas.espresso.common.pages.compose.CalendarToDoDetailsPage +import com.instructure.canvas.espresso.common.pages.compose.ToDoFilterPage +import com.instructure.canvas.espresso.common.pages.compose.ToDoListPage +import com.instructure.canvas.espresso.mockcanvas.MockCanvas +import com.instructure.canvas.espresso.mockcanvas.addAssignment +import com.instructure.canvas.espresso.mockcanvas.addCourse +import com.instructure.canvas.espresso.mockcanvas.addCourseCalendarEvent +import com.instructure.canvas.espresso.mockcanvas.addDiscussionTopicToAssignment +import com.instructure.canvas.espresso.mockcanvas.addDiscussionTopicToCourse +import com.instructure.canvas.espresso.mockcanvas.addEnrollment +import com.instructure.canvas.espresso.mockcanvas.addPlannable +import com.instructure.canvas.espresso.mockcanvas.addQuizToCourse +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.Quiz +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.pandautils.R +import org.junit.Test +import java.util.Calendar +import java.util.Date + +abstract class ToDoListInteractionTest : CanvasComposeTest() { + + private val toDoListPage = ToDoListPage(composeTestRule) + private val toDoFilterPage = ToDoFilterPage(composeTestRule) + private val todoDetailsPage = CalendarToDoDetailsPage(composeTestRule) + private val eventDetailsPage = CalendarEventDetailsPage(composeTestRule) + private val calendarScreenPage = CalendarScreenPage(composeTestRule) + + @Test + fun selectAssignmentOpensAssignmentDetails() { + val data = initData() + + val course = data.courses.values.first() + val assignment = data.addAssignment( + course.id, + name = "Test Assignment", + dueAt = Calendar.getInstance().time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.clickOnItem(assignment.name!!) + + assertAssignmentDetailsTitle(assignment.name!!) + } + + @Test + fun selectQuizOpensAssignmentDetails() { + val data = initData() + + val course = data.courses.values.first() + val quiz = data.addQuizToCourse( + title = "Test Quiz", + course = course, + quizType = Quiz.TYPE_ASSIGNMENT, + dueAt = Calendar.getInstance().time.toApiString(), + description = "Here's a description!" + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.clickOnItem(quiz.title!!) + + assertAssignmentDetailsTitle(quiz.title!!) + } + + @Test + fun selectDiscussionOpensAssignmentDetails() { + val data = initData() + + val course = data.courses.values.first() + val user = getLoggedInUser() + val discussionAssignment = data.addAssignment( + courseId = course.id, + submissionTypeList = listOf(Assignment.SubmissionType.DISCUSSION_TOPIC), + name = "Discussion assignment", + pointsPossible = 12, + dueAt = Calendar.getInstance().time.toApiString() + ) + + val discussion = data.addDiscussionTopicToCourse( + topicTitle = "Discussion topic", + course = course, + user = user, + assignment = discussionAssignment + ) + + data.addDiscussionTopicToAssignment(discussionAssignment, discussion) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.clickOnItem(discussionAssignment.name!!) + + assertAssignmentDetailsTitle(discussionAssignment.name!!) + } + + @Test + fun selectCalendarEventOpensEventDetails() { + val data = initData() + + val course = data.courses.values.first() + val event = data.addCourseCalendarEvent( + course = course, + startDate = Date().toApiString(), + title = "Test Event", + description = "Test Description" + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.clickFilterButton() + toDoFilterPage.toggleShowCalendarEvents() + toDoFilterPage.clickDone() + composeTestRule.waitForIdle() + toDoListPage.clickOnItem(event.title!!) + + eventDetailsPage.assertEventTitle(event.title!!) + } + + @Test + fun selectPersonalToDoOpensToDoDetails() { + val data = initData() + + val course = data.courses.values.first() + val user = getLoggedInUser() + val todo = data.addPlannable( + name = "Test Personal Todo", + course = course, + userId = user.id, + type = PlannableType.PLANNER_NOTE, + date = Date() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.clickFilterButton() + toDoFilterPage.toggleShowPersonalToDos() + toDoFilterPage.clickDone() + composeTestRule.waitForIdle() + toDoListPage.clickOnItem(todo.plannable.title) + + todoDetailsPage.assertTitle(todo.plannable.title) + } + + @Test + fun checkboxMarksItemAsDone() { + val data = initData() + + val course = data.courses.values.first() + val assignment = data.addAssignment( + course.id, + name = "Test Checkbox Assignment", + dueAt = Calendar.getInstance().time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.assertItemDisplayed(assignment.name!!) + toDoListPage.clickCheckbox(assignment.id) + + // Wait for snackbar to appear + toDoListPage.waitForSnackbar(assignment.name!!) + toDoListPage.assertSnackbarDisplayed(assignment.name!!) + + // Click undo button in snackbar + toDoListPage.clickSnackbarUndo() + + // Wait for item to reappear after undo + toDoListPage.waitForItemToAppear(assignment.name!!) + toDoListPage.assertItemDisplayed(assignment.name!!) + } + + @Test + fun swipeRightMarksItemAsDone() { + val data = initData() + + val course = data.courses.values.first() + val assignment = data.addAssignment( + course.id, + name = "Test Swipe Assignment", + dueAt = Calendar.getInstance().time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.assertItemDisplayed(assignment.name!!) + toDoListPage.swipeItemRight(assignment.id) + + // Wait for snackbar to appear + toDoListPage.waitForSnackbar(assignment.name!!) + toDoListPage.assertSnackbarDisplayed(assignment.name!!) + } + + @Test + fun swipeLeftMarksItemAsDone() { + val data = initData() + + val course = data.courses.values.first() + val assignment = data.addAssignment( + course.id, + name = "Test Swipe Left Assignment", + dueAt = Calendar.getInstance().time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.assertItemDisplayed(assignment.name!!) + toDoListPage.swipeItemLeft(assignment.id) + + // Wait for snackbar to appear + toDoListPage.waitForSnackbar(assignment.name!!) + toDoListPage.assertSnackbarDisplayed(assignment.name!!) + } + + @Test + fun clickDateBadgeNavigatesToCalendar() { + val data = initData() + + val course = data.courses.values.first() + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_MONTH, 3) // 3 days from now + val dueDate = calendar.time + val dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH) + + val assignment = data.addAssignment( + course.id, + name = "Test Date Badge Assignment", + dueAt = dueDate.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + + // Ensure the assignment is visible by setting future range to cover 3 days ahead + toDoListPage.clickFilterButton() + toDoFilterPage.selectFutureDateRange(R.string.todoFilterNextWeek) + toDoFilterPage.clickDone() + + composeTestRule.waitForIdle() + toDoListPage.assertItemDisplayed(assignment.name!!) + toDoListPage.clickDateBadge(dayOfMonth) + + composeTestRule.waitForIdle() + calendarScreenPage.assertCalendarPageTitle() + } + + @Test + fun openFilterScreen() { + val data = initData() + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.clickFilterButton() + toDoFilterPage.assertFilterScreenTitle() + } + + @Test + fun onlyFilteredItemsAreDisplayedWhenFilteringByCompletedItems() { + val data = initData() + + val course = data.courses.values.first() + val assignment = data.addAssignment( + course.id, + name = "Assignment to Complete", + dueAt = Calendar.getInstance().time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.assertItemDisplayed(assignment.name!!) + + // Mark the assignment as done + toDoListPage.clickCheckbox(assignment.id) + + // Wait for item to disappear + toDoListPage.waitForItemToDisappear(assignment.name!!) + toDoListPage.assertItemNotDisplayed(assignment.name!!) + + // Open filter and enable "Show Completed" + toDoListPage.clickFilterButton() + toDoFilterPage.toggleShowCompleted() + toDoFilterPage.clickDone() + + composeTestRule.waitForIdle() + + // Verify the completed assignment is now displayed + toDoListPage.assertItemDisplayed(assignment.name!!) + } + + @Test + fun emptyStateDisplayedWhenNoToDos() { + val data = initData() + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.assertEmptyState() + } + + @Test + fun personalToDosFilterShowsPersonalToDos() { + val data = initData() + + val course = data.courses.values.first() + val user = getLoggedInUser() + val assignment = data.addAssignment( + course.id, + name = "Regular Assignment", + dueAt = Calendar.getInstance().time.toApiString() + ) + + val personalTodo = data.addPlannable( + name = "Personal Todo Item", + course = course, + userId = user.id, + type = PlannableType.PLANNER_NOTE, + date = Date() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + + // By default, personal todos are hidden + toDoListPage.assertItemDisplayed(assignment.name!!) + toDoListPage.assertItemNotDisplayed(personalTodo.plannable.title) + + // Enable personal todos filter + toDoListPage.clickFilterButton() + toDoFilterPage.toggleShowPersonalToDos() + toDoFilterPage.clickDone() + + composeTestRule.waitForIdle() + + // Verify personal todo is now displayed + toDoListPage.assertItemDisplayed(assignment.name!!) + toDoListPage.assertItemDisplayed(personalTodo.plannable.title) + } + + @Test + fun calendarEventsFilterShowsCalendarEvents() { + val data = initData() + + val course = data.courses.values.first() + val assignment = data.addAssignment( + course.id, + name = "Regular Assignment", + dueAt = Calendar.getInstance().time.toApiString() + ) + + val event = data.addCourseCalendarEvent( + course = course, + startDate = Date().toApiString(), + title = "Calendar Event", + description = "Event Description" + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + + // By default, calendar events are hidden + toDoListPage.assertItemDisplayed(assignment.name!!) + toDoListPage.assertItemNotDisplayed(event.title!!) + + // Enable calendar events filter + toDoListPage.clickFilterButton() + toDoFilterPage.toggleShowCalendarEvents() + toDoFilterPage.clickDone() + + composeTestRule.waitForIdle() + + // Verify calendar event is now displayed + toDoListPage.assertItemDisplayed(assignment.name!!) + toDoListPage.assertItemDisplayed(event.title!!) + } + + @Test + fun filterCloseWithoutSavingDoesNotApplyChanges() { + val data = initData() + + val course = data.courses.values.first() + val assignment = data.addAssignment( + course.id, + name = "Assignment to Complete", + dueAt = Calendar.getInstance().time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.assertItemDisplayed(assignment.name!!) + + // Mark assignment as done so it's hidden + toDoListPage.clickCheckbox(assignment.id) + toDoListPage.waitForItemToDisappear(assignment.name!!) + toDoListPage.assertItemNotDisplayed(assignment.name!!) + + // Open filter and toggle "Show Completed" but close without saving + toDoListPage.clickFilterButton() + toDoFilterPage.toggleShowCompleted() + toDoFilterPage.clickClose() + + composeTestRule.waitForIdle() + + // Verify completed item is still hidden (filter was not applied) + toDoListPage.assertItemNotDisplayed(assignment.name!!) + toDoListPage.assertFilterIconOutline() + } + + @Test + fun filterCloseWithSavingAppliesChanges() { + val data = initData() + + val course = data.courses.values.first() + val assignment = data.addAssignment( + course.id, + name = "Assignment to Complete", + dueAt = Calendar.getInstance().time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + toDoListPage.assertItemDisplayed(assignment.name!!) + + // Mark assignment as done so it's hidden + toDoListPage.clickCheckbox(assignment.id) + toDoListPage.waitForItemToDisappear(assignment.name!!) + toDoListPage.assertItemNotDisplayed(assignment.name!!) + + // Open filter, toggle "Show Completed" and save + toDoListPage.clickFilterButton() + toDoFilterPage.toggleShowCompleted() + toDoFilterPage.clickDone() + + composeTestRule.waitForIdle() + + // Verify completed item is now displayed (filter was applied) + toDoListPage.assertItemDisplayed(assignment.name!!) + toDoListPage.assertFilterIconFilled() + } + + @Test + fun pastDateRangeFilterShowsOlderItems() { + val data = initData() + + val course = data.courses.values.first() + val calendar = Calendar.getInstance() + + // Create assignment 3 weeks ago + calendar.add(Calendar.WEEK_OF_YEAR, -3) + val threeWeeksAgo = calendar.time + val oldAssignment = data.addAssignment( + course.id, + name = "Old Assignment", + dueAt = threeWeeksAgo.toApiString() + ) + + // Create assignment today + calendar.time = Date() + val todayAssignment = data.addAssignment( + course.id, + name = "Today Assignment", + dueAt = calendar.time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + + // By default, past date range is "4 Weeks Ago" so assignment from 3 weeks ago should be visible + toDoListPage.assertItemDisplayed(todayAssignment.name!!) + toDoListPage.assertItemDisplayed(oldAssignment.name!!) + + // Change past date range to "Last Week" to hide the older assignment + toDoListPage.clickFilterButton() + toDoFilterPage.selectPastDateRange(R.string.todoFilterLastWeek) + toDoFilterPage.clickDone() + + composeTestRule.waitForIdle() + + // Assignment from 3 weeks ago should now be hidden (outside 1-week range) + toDoListPage.assertItemDisplayed(todayAssignment.name!!) + toDoListPage.assertItemNotDisplayed(oldAssignment.name!!) + + // Change past date range to "4 Weeks Ago" to show the older assignment again + toDoListPage.clickFilterButton() + toDoFilterPage.selectPastDateRange(R.string.todoFilterFourWeeks) + toDoFilterPage.clickDone() + + composeTestRule.waitForIdle() + + // Assignment from 3 weeks ago should now be visible again (within 4-week range) + toDoListPage.assertItemDisplayed(todayAssignment.name!!) + toDoListPage.assertItemDisplayed(oldAssignment.name!!) + } + + @Test + fun futureDateRangeFilterShowsFutureItems() { + val data = initData() + + val course = data.courses.values.first() + val calendar = Calendar.getInstance() + + // Create assignment today + val todayAssignment = data.addAssignment( + course.id, + name = "Today Assignment", + dueAt = calendar.time.toApiString() + ) + + // Create assignment 2 weeks from now + calendar.add(Calendar.WEEK_OF_YEAR, 2) + val futureAssignment = data.addAssignment( + course.id, + name = "Future Assignment", + dueAt = calendar.time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + + // By default, future date range is "Next Week" so assignment from 2 weeks ahead should be hidden + toDoListPage.assertItemDisplayed(todayAssignment.name!!) + toDoListPage.assertItemNotDisplayed(futureAssignment.name!!) + + // Change future date range to "In 2 Weeks" + toDoListPage.clickFilterButton() + toDoFilterPage.selectFutureDateRange(R.string.todoFilterInTwoWeeks) + toDoFilterPage.clickDone() + + composeTestRule.waitForIdle() + + // Now both assignments should be visible + toDoListPage.assertItemDisplayed(todayAssignment.name!!) + toDoListPage.assertItemDisplayed(futureAssignment.name!!) + } + + @Test + fun favoriteCoursesFilterShowsOnlyFavoriteCoursesItems() { + val data = initData() + val user = getLoggedInUser() + + val favoriteCourse = data.courses.values.first() + favoriteCourse.isFavorite = true + + val nonFavoriteCourse = data.addCourse(isFavorite = false) + data.addEnrollment(user, nonFavoriteCourse, Enrollment.EnrollmentType.Student) + + val favoriteAssignment = data.addAssignment( + favoriteCourse.id, + name = "Favorite Course Assignment", + dueAt = Calendar.getInstance().time.toApiString() + ) + + val nonFavoriteAssignment = data.addAssignment( + nonFavoriteCourse.id, + name = "Non-Favorite Course Assignment", + dueAt = Calendar.getInstance().time.toApiString() + ) + + goToToDoList(data) + + composeTestRule.waitForIdle() + + // By default, both assignments should be visible + toDoListPage.assertItemDisplayed(favoriteAssignment.name!!) + toDoListPage.assertItemDisplayed(nonFavoriteAssignment.name!!) + + // Enable favorite courses filter + toDoListPage.clickFilterButton() + toDoFilterPage.toggleFavoriteCourses() + toDoFilterPage.clickDone() + + composeTestRule.waitForIdle() + + // Only the favorite course assignment should be visible + toDoListPage.assertItemDisplayed(favoriteAssignment.name!!) + toDoListPage.assertItemNotDisplayed(nonFavoriteAssignment.name!!) + } + + override fun displaysPageObjects() = Unit + + abstract fun goToToDoList(data: MockCanvas) + + abstract fun initData(): MockCanvas + + abstract fun getLoggedInUser(): User + + abstract fun assertAssignmentDetailsTitle(title: String) +} diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/AssignmentDetailsPage.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/AssignmentDetailsPage.kt index 6ae2222815..50aede23a9 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/AssignmentDetailsPage.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/AssignmentDetailsPage.kt @@ -178,6 +178,10 @@ open class AssignmentDetailsPage(val moduleItemInteractions: ModuleItemInteracti onView(withId(R.id.submissionStatus)).waitForCheck(matches(withText(statusResourceId))) } + fun assertCustomStatus(status: String) { + onView(withId(R.id.submissionStatus)).waitForCheck(matches(withText(status))) + } + fun assertStatusSubmitted() { assertStatus(R.string.submitted) } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/AssignmentListPage.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/AssignmentListPage.kt index f53779a31b..b58882b64b 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/AssignmentListPage.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/AssignmentListPage.kt @@ -19,6 +19,8 @@ package com.instructure.canvas.espresso.common.pages.compose import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasAnyChild import androidx.compose.ui.test.hasAnyDescendant import androidx.compose.ui.test.hasAnySibling import androidx.compose.ui.test.hasContentDescription @@ -99,8 +101,8 @@ class AssignmentListPage(private val composeTestRule: ComposeTestRule) { assertHasAssignmentCommon(assignment.name, assignment.dueAt, null) } - fun assertHasAssignment(assignment: AssignmentApiModel, expectedGrade: String? = null) { - assertHasAssignmentCommon(assignment.name, assignment.dueAt, expectedGrade) + fun assertHasAssignment(assignment: AssignmentApiModel, expectedGrade: String? = null, assignmentStatus: String? = null) { + assertHasAssignmentCommon(assignment.name, assignment.dueAt, expectedGrade, assignmentStatus) } fun assertHasAssignment(assignment: Assignment, expectedGrade: String? = null) { @@ -113,7 +115,7 @@ class AssignmentListPage(private val composeTestRule: ComposeTestRule) { } fun assertHasAssignmentWithCheckpoints(assignmentName: String, dueAtString: String = "No due date", expectedGrade: String? = null) { - assertHasAssignmentCommon(assignmentName, dueAtString, expectedGrade, true) + assertHasAssignmentCommon(assignmentName, dueAtString, expectedGrade, hasCheckPoints = true) } fun assertAssignmentNotDisplayed(assignmentName: String) { @@ -160,7 +162,7 @@ class AssignmentListPage(private val composeTestRule: ComposeTestRule) { composeTestRule.onNode( hasTestTag("checkpointGradeText") and hasText(gradeAdditionalReplies), useUnmergedTree = true).assertIsDisplayed() } - private fun assertHasAssignmentCommon(assignmentName: String, assignmentDueAt: String?, expectedLabel: String? = null, hasCheckPoints : Boolean = false) { + private fun assertHasAssignmentCommon(assignmentName: String, assignmentDueAt: String?, 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) { @@ -199,15 +201,27 @@ class AssignmentListPage(private val composeTestRule: ComposeTestRule) { retryWithIncreasingDelay(times = 10, maxDelay = 4000, catchBlock = { refresh() }) { // Check that grade is present, if that is specified - if (expectedLabel != null) { + if (expectedGradeLabel != null) { composeTestRule.onNode( hasText(assignmentName).and( - hasParent(hasAnyDescendant(hasText(expectedLabel, substring = true))) + 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() + } + } } fun assertQuizNotDisplayed(quiz: QuizApiModel) { diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxDetailsPage.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxDetailsPage.kt index adb2e89c11..00350c49ba 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxDetailsPage.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/InboxDetailsPage.kt @@ -141,6 +141,8 @@ class InboxDetailsPage(private val composeTestRule: ComposeTestRule) { composeTestRule.onNode(hasTestTag("messageMenuItem").and(hasText(buttonLabel)), true) .performClick() + + composeTestRule.waitForIdle() } fun pressOverflowMenuItemForConversation(buttonLabel: String) { diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/ToDoFilterPage.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/ToDoFilterPage.kt new file mode 100644 index 0000000000..198a455697 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/ToDoFilterPage.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2025 - 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.canvas.espresso.common.pages.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.getStringFromResource +import com.instructure.pandautils.R + +class ToDoFilterPage(private val composeTestRule: ComposeTestRule) : BasePage() { + + fun assertFilterScreenTitle() { + composeTestRule.onNodeWithText(getStringFromResource(R.string.todoFilterPreferences)) + .assertIsDisplayed() + } + + fun clickDone() { + composeTestRule.onNodeWithText(getStringFromResource(R.string.done)) + .performClick() + composeTestRule.waitForIdle() + } + + fun toggleShowPersonalToDos() { + composeTestRule.onNodeWithText(getStringFromResource(R.string.todoFilterShowPersonalToDos)) + .performClick() + composeTestRule.waitForIdle() + } + + fun toggleShowCalendarEvents() { + composeTestRule.onNodeWithText(getStringFromResource(R.string.todoFilterShowCalendarEvents)) + .performClick() + composeTestRule.waitForIdle() + } + + fun toggleShowCompleted() { + composeTestRule.onNodeWithText(getStringFromResource(R.string.todoFilterShowCompleted)) + .performClick() + composeTestRule.waitForIdle() + } + + fun toggleFavoriteCourses() { + composeTestRule.onNodeWithText(getStringFromResource(R.string.todoFilterFavoriteCoursesOnly)) + .performClick() + composeTestRule.waitForIdle() + } + + fun clickClose() { + composeTestRule.onNodeWithContentDescription(getStringFromResource(R.string.close)) + .performClick() + composeTestRule.waitForIdle() + } + + fun selectPastDateRange(labelResId: Int) { + val labelText = getStringFromResource(labelResId) + composeTestRule.onNodeWithTag("ToDoFilterContent") + .performScrollToNode(hasText(labelText)) + composeTestRule.onNodeWithText(labelText) + .performClick() + composeTestRule.waitForIdle() + } + + fun selectFutureDateRange(labelResId: Int) { + val labelText = getStringFromResource(labelResId) + composeTestRule.onNodeWithTag("ToDoFilterContent") + .performScrollToNode(hasText(labelText)) + composeTestRule.onNodeWithText(labelText) + .performClick() + composeTestRule.waitForIdle() + } +} diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/ToDoListPage.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/ToDoListPage.kt new file mode 100644 index 0000000000..ef1be4b1d4 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/common/pages/compose/ToDoListPage.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2025 - 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.canvas.espresso.common.pages.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.getStringFromResource +import com.instructure.pandautils.R + +class ToDoListPage(private val composeTestRule: ComposeTestRule) : BasePage() { + + fun clickFilterButton() { + composeTestRule.onNodeWithContentDescription(getStringFromResource(R.string.a11y_contentDescriptionToDoFilter)) + .performClick() + composeTestRule.waitForIdle() + } + + fun clickOnItem(itemTitle: String) { + composeTestRule.onNodeWithText(itemTitle).performClick() + composeTestRule.waitForIdle() + } + + fun assertItemDisplayed(itemTitle: String) { + composeTestRule.onNodeWithText(itemTitle).assertIsDisplayed() + } + + fun assertItemNotDisplayed(itemTitle: String) { + composeTestRule.onNodeWithText(itemTitle).assertDoesNotExist() + } + + fun clickCheckbox(itemId: Long) { + composeTestRule.onNodeWithTag("todoCheckbox_$itemId") + .performClick() + composeTestRule.waitForIdle() + } + + fun swipeItemLeft(itemId: Long) { + composeTestRule.waitForIdle() + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.onNodeWithTag("todoItem_$itemId").performTouchInput { + swipeLeft() + } + + composeTestRule.mainClock.advanceTimeBy(1000L) + composeTestRule.mainClock.autoAdvance = true + composeTestRule.waitForIdle() + } + + fun swipeItemRight(itemId: Long) { + composeTestRule.waitForIdle() + composeTestRule.mainClock.autoAdvance = false + + composeTestRule.onNodeWithTag("todoItem_$itemId").performTouchInput { + swipeRight() + } + + composeTestRule.mainClock.advanceTimeBy(1000L) + composeTestRule.mainClock.autoAdvance = true + composeTestRule.waitForIdle() + } + + fun assertSnackbarDisplayed(itemTitle: String) { + val message = getStringFromResource(R.string.todoMarkedAsDone, itemTitle) + composeTestRule.onNodeWithText(message).assertIsDisplayed() + } + + fun clickSnackbarUndo() { + composeTestRule.onNodeWithText(getStringFromResource(R.string.todoMarkedAsDoneSnackbarUndo)) + .performClick() + composeTestRule.waitForIdle() + } + + fun clickDateBadge(dayOfMonth: Int) { + composeTestRule.onNodeWithText(dayOfMonth.toString()).performClick() + composeTestRule.waitForIdle() + } + + fun assertFilterIconOutline() { + composeTestRule.onNodeWithContentDescription(getStringFromResource(R.string.a11y_contentDescriptionToDoFilter)) + .assertExists() + } + + fun assertFilterIconFilled() { + composeTestRule.onNodeWithContentDescription(getStringFromResource(R.string.a11y_contentDescriptionToDoFilter)) + .assertExists() + } + + fun assertEmptyState() { + composeTestRule.onNodeWithText(getStringFromResource(R.string.noToDosForNow)) + .assertIsDisplayed() + } + + fun waitForSnackbar(itemTitle: String, timeoutMillis: Long = 5000) { + val message = getStringFromResource(R.string.todoMarkedAsDone, itemTitle) + composeTestRule.waitUntil(timeoutMillis) { + composeTestRule.onAllNodesWithText(message).fetchSemanticsNodes().isNotEmpty() + } + } + + fun waitForItemToDisappear(itemTitle: String, timeoutMillis: Long = 5000) { + composeTestRule.waitUntil(timeoutMillis) { + composeTestRule.onAllNodesWithText(itemTitle).fetchSemanticsNodes().isEmpty() + } + } + + fun waitForItemToAppear(itemTitle: String, timeoutMillis: Long = 5000) { + composeTestRule.waitUntil(timeoutMillis) { + composeTestRule.onAllNodesWithText(itemTitle).fetchSemanticsNodes().isNotEmpty() + } + } +} diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/MockCanvas.kt index 58ddd511fc..5179676390 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/MockCanvas.kt @@ -1113,6 +1113,11 @@ fun MockCanvas.addAssignment( ) : Assignment { val assignmentId = newItemId() val submissionTypeListRawStrings = submissionTypeList.map { it.apiString } + val generatedHtmlUrl = if (htmlUrl.isNullOrEmpty()) { + "https://$domain/courses/$courseId/assignments/$assignmentId" + } else { + htmlUrl + } var assignment = Assignment( id = assignmentId, assignmentGroupId = assignmentGroupId, @@ -1138,7 +1143,7 @@ fun MockCanvas.addAssignment( ), gradingType = gradingType, discussionTopicHeader = discussionTopicHeader, - htmlUrl = htmlUrl, + htmlUrl = generatedHtmlUrl, submission = submission, checkpoints = checkpoints ) @@ -1890,19 +1895,22 @@ fun MockCanvas.addQuizToCourse( var assignment : Assignment? = null // For quizzes that are actual assignments, create an associated Assignment object - if(quizType == Quiz.TYPE_ASSIGNMENT) { + if (quizType == Quiz.TYPE_ASSIGNMENT) { + val assignmentId = newItemId() + val assignmentUrl = "https://$domain/api/v1/courses/${course.id}/assignments/$assignmentId" assignment = Assignment( - id = newItemId(), - name = title, - description = description, - dueAt = dueAt, - submissionTypesRaw = listOf("online_quiz"), - quizId = quizId, - courseId = course.id, - lockAt = lockAt, - unlockAt = unlockAt, - allDates = listOf(AssignmentDueDate(id = newItemId(), dueAt = dueAt, lockAt = lockAt, unlockAt = unlockAt)) - ) + id = assignmentId, + name = title, + description = description, + dueAt = dueAt, + submissionTypesRaw = listOf("online_quiz"), + quizId = quizId, + courseId = course.id, + lockAt = lockAt, + unlockAt = unlockAt, + allDates = listOf(AssignmentDueDate(id = newItemId(), dueAt = dueAt, lockAt = lockAt, unlockAt = unlockAt)), + htmlUrl = assignmentUrl + ) assignments.put(assignment.id, assignment) } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt index ef196dfbc5..d6bd104e24 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt @@ -18,7 +18,6 @@ package com.instructure.canvas.espresso.mockcanvas.endpoints import android.util.Log import com.google.gson.Gson -import com.instructure.canvas.espresso.mockCanvas.endpoints.CareerEndpoint import com.instructure.canvas.espresso.mockcanvas.Endpoint import com.instructure.canvas.espresso.mockcanvas.addDiscussionTopicToCourse import com.instructure.canvas.espresso.mockcanvas.addPlannable @@ -370,6 +369,14 @@ object ApiEndpoint : Endpoint( ), Segment("planner_notes") to Endpoint( LongId(PathVars::plannerNoteId) to Endpoint { + GET { + val plannable = data.todos.find { it.plannable.id == pathVars.plannerNoteId }?.plannable + if (plannable != null) { + request.successResponse(plannable) + } else { + request.unauthorizedResponse() + } + } DELETE { val plannerNote = data.todos.find { it.plannable.id == pathVars.plannerNoteId } if (plannerNote != null) { diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt index 28f6dd32a1..acfcfc0e4d 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt @@ -14,7 +14,7 @@ * along with this program. If not, see . * */ -package com.instructure.canvas.espresso.mockCanvas.endpoints +package com.instructure.canvas.espresso.mockcanvas.endpoints import com.instructure.canvas.espresso.mockcanvas.Endpoint import com.instructure.canvas.espresso.mockcanvas.utils.Segment diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/UserEndpoints.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/UserEndpoints.kt index 96c8b9ecd9..0fd4d2c433 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/UserEndpoints.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/UserEndpoints.kt @@ -47,6 +47,7 @@ import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.models.postmodels.CreateObserverThresholdWrapper import com.instructure.canvasapi2.models.toPlannerItems import com.instructure.canvasapi2.utils.pageview.PandataInfo +import com.instructure.canvasapi2.utils.toDate import com.instructure.pandautils.utils.fromJson import com.instructure.pandautils.utils.orDefault import okio.Buffer @@ -124,6 +125,10 @@ object UserEndpoint : Endpoint( val userId = pathVars.userId val userCourseIds = data.enrollments.values.filter { it.userId == userId }.map { it.courseId } + // Get date filter parameters + val startDate = request.url.queryParameter("start_date").toDate() + val endDate = request.url.queryParameter("end_date").toDate() + val todos = data.todos.filter { it.userId == userId } val events = data.courseCalendarEvents @@ -136,7 +141,6 @@ object UserEndpoint : Endpoint( .toPlannerItems(PlannableType.CALENDAR_EVENT) // Gather our assignments - // Currently we assume all the assignments are due today val plannerItemsList = data.assignments.values .filter { userCourseIds.contains(it.courseId) } .map { assignment -> @@ -155,11 +159,15 @@ object UserEndpoint : Endpoint( } val plannable = Plannable(plannableId, assignment.name ?: "", assignment.courseId, null, userId, null, assignment.dueDate, assignment.id, null, null, null, null, null) - PlannerItem(assignment.courseId, null, userId, CanvasContext.Type.COURSE.apiString, contextName, plannableType, plannable, plannableDate, null, SubmissionState(), false) + PlannerItem(assignment.courseId, null, userId, CanvasContext.Type.COURSE.apiString, contextName, plannableType, plannable, plannableDate, assignment.htmlUrl, SubmissionState(), false) } .plus(todos) .plus(events) .plus(userEvents) + .filter { + if (startDate == null || endDate == null) return@filter true + it.plannableDate.time in startDate.time..endDate.time + } request.successResponse(plannerItemsList) } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeGetHorizonCourseManager.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeGetHorizonCourseManager.kt index 207a909deb..f8fa0f968f 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeGetHorizonCourseManager.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeGetHorizonCourseManager.kt @@ -90,25 +90,31 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager { fun getCourses(): List { val courses = MockCanvas.data.courses.values.toList() - val activeCourse = CourseWithProgress( - courseId = courses[0].id, - courseName = courses[0].name, - courseSyllabus = "Syllabus for Course 1", - progress = 0.25 - ) - val completedCourse = CourseWithProgress( - courseId = courses[1].id, - courseName = courses[1].name, - courseSyllabus = "Syllabus for Course 2", - progress = 1.0 - ) - val invitedCourse = CourseWithProgress( - courseId = courses[2].id, - courseName = courses[2].name, - courseSyllabus = null, - progress = 0.0 - ) + val activeCourse = if (courses.size > 0) { + CourseWithProgress( + courseId = courses[0].id, + courseName = courses[0].name, + courseSyllabus = "Syllabus for Course 1", + progress = 0.25 + ) + } else { null } + val completedCourse = if (courses.size > 1) { + CourseWithProgress( + courseId = courses[1].id, + courseName = courses[1].name, + courseSyllabus = "Syllabus for Course 2", + progress = 1.0 + ) + } else { null } + val invitedCourse = if (courses.size > 2) { + CourseWithProgress( + courseId = courses[2].id, + courseName = courses[2].name, + courseSyllabus = null, + progress = 0.0 + ) + } else { null } - return listOf(activeCourse, completedCourse, invitedCourse) + return listOfNotNull(activeCourse, completedCourse, invitedCourse) } } \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeRedwoodApiManager.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeRedwoodApiManager.kt new file mode 100644 index 0000000000..dd6d253a4f --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeRedwoodApiManager.kt @@ -0,0 +1,108 @@ +/* + * 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.canvas.espresso.mockcanvas.fakes + +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager +import com.instructure.redwood.QueryNotesQuery +import com.instructure.redwood.type.NoteFilterInput +import com.instructure.redwood.type.OrderByInput +import java.util.Date + +class FakeRedwoodApiManager : RedwoodApiManager { + private val notes = mutableListOf() + private var noteIdCounter = 1 + + override suspend fun getNotes( + filter: NoteFilterInput?, + firstN: Int?, + lastN: Int?, + after: String?, + before: String?, + orderBy: OrderByInput?, + forceNetwork: Boolean + ): QueryNotesQuery.Notes { + val edges = notes.map { note -> + QueryNotesQuery.Edge( + cursor = note.id, + node = note + ) + } + + val pageInfo = QueryNotesQuery.PageInfo( + hasNextPage = false, + hasPreviousPage = false, + startCursor = edges.firstOrNull()?.cursor, + endCursor = edges.lastOrNull()?.cursor + ) + + return QueryNotesQuery.Notes( + edges = edges, + pageInfo = pageInfo + ) + } + + override suspend fun createNote( + courseId: String, + objectId: String, + objectType: String, + userText: String?, + notebookType: String?, + highlightData: NoteHighlightedData? + ) { + val note = QueryNotesQuery.Node( + id = "note_${noteIdCounter++}", + rootAccountUuid = "test-root-account", + userId = "test-user", + courseId = courseId, + objectId = objectId, + objectType = objectType, + userText = userText, + reaction = notebookType?.let { listOf(notebookType) }, + highlightData = highlightData, + createdAt = Date(), + updatedAt = Date() + ) + notes.add(note) + } + + override suspend fun updateNote( + id: String, + userText: String?, + notebookType: String?, + highlightData: NoteHighlightedData? + ) { + val index = notes.indexOfFirst { it.id == id } + if (index != -1) { + val existingNote = notes[index] + notes[index] = existingNote.copy( + userText = userText, + reaction = notebookType?.let { listOf(it) }, + updatedAt = Date() + ) + } + } + + override suspend fun deleteNote(noteId: String) { + notes.removeAll { it.id == noteId } + } + + fun reset() { + notes.clear() + noteIdCounter = 1 + } +} diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt index f83a3e56fa..e4310047dd 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt @@ -49,6 +49,10 @@ fun BasePage.withAncestor(id: Int): Matcher = ViewMatchers.isDescendantOfA fun BasePage.withAncestor(matcher: Matcher): Matcher = ViewMatchers.isDescendantOfA(matcher) +fun BasePage.withChild(id: Int): Matcher = ViewMatchers.withChild(withId(id)) + +fun BasePage.withChild(matcher: Matcher): Matcher = ViewMatchers.withChild(matcher) + fun BasePage.withDescendant(descendantMatcher: Matcher): Matcher = ViewMatchers.hasDescendant(descendantMatcher) fun BasePage.withText(arg0: String): Matcher = ViewMatchers.withText(arg0) diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt index 3077e27a48..7ec01cd6c4 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt @@ -36,6 +36,9 @@ object CourseAPI { @get:GET("users/self/favorites/courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=current_grading_period_scores&include[]=course_image&include[]=favorites") val favoriteCourses: Call> + @GET("users/self/favorites/courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=current_grading_period_scores&include[]=course_image&include[]=favorites&state[]=completed&state[]=available") + suspend fun getFavoriteCourses(@Tag restParams: RestParams): DataResult> + @get:GET("dashboard/dashboard_cards") val dashboardCourses: Call> diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/EnrollmentAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/EnrollmentAPI.kt index 4983bb1b2f..fdf7288133 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/EnrollmentAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/EnrollmentAPI.kt @@ -83,9 +83,18 @@ object EnrollmentAPI { @Query("type[]") types: List?, @Query("state[]") states: List?): Call> + @GET("users/self/enrollments") + suspend fun getFirstPageSelfEnrollments( + @Query("type[]") types: List?, + @Query("state[]") states: List?, + @Tag params: RestParams): DataResult> + @POST("courses/{courseId}/enrollments/{enrollmentId}/{action}") fun handleInvite(@Path("courseId") courseId: Long, @Path("enrollmentId") enrollmentId: Long, @Path("action") action: String): Call + @POST("courses/{courseId}/enrollments/{enrollmentId}/{action}") + suspend fun handleInvite(@Path("courseId") courseId: Long, @Path("enrollmentId") enrollmentId: Long, @Path("action") action: String, @Tag params: RestParams): DataResult + @POST("courses/{courseId}/enrollments/{enrollmentId}/accept") suspend fun acceptInvite(@Path("courseId") courseId: Long, @Path("enrollmentId") enrollmentId: Long, @Tag params: RestParams): DataResult diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/graphql/RedwoodModule.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/graphql/RedwoodModule.kt new file mode 100644 index 0000000000..411f5a5373 --- /dev/null +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/graphql/RedwoodModule.kt @@ -0,0 +1,37 @@ +/* + * 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.canvasapi2.di.graphql + +import com.apollographql.apollo.ApolloClient +import com.instructure.canvasapi2.di.RedwoodApolloClient +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManagerImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class RedwoodModule { + @Provides + fun provideRedwoodApiManager( + @RedwoodApolloClient redwoodClient: ApolloClient + ): RedwoodApiManager { + return RedwoodApiManagerImpl(redwoodClient) + } +} diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/redwood/RedwoodApiManager.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/redwood/RedwoodApiManager.kt index c9e168c76c..0355282c5b 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/redwood/RedwoodApiManager.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/redwood/RedwoodApiManager.kt @@ -71,8 +71,8 @@ data class NoteHighlightedDataRange( ) data class NoteHighlightedDataTextPosition( - val start: Int, - val end: Int + val start: Int = 0, + val end: Int = 0 ) data class NoteItem( @@ -89,9 +89,7 @@ data class NoteItem( val updatedAt: Date?, ) -class RedwoodApiManager @Inject constructor( - @RedwoodApolloClient private val redwoodClient: ApolloClient -) { +interface RedwoodApiManager { suspend fun getNotes( filter: NoteFilterInput? = null, firstN: Int? = null, @@ -99,6 +97,39 @@ class RedwoodApiManager @Inject constructor( after: String? = null, before: String? = null, orderBy: OrderByInput? = null, + forceNetwork: Boolean = false + ): QueryNotesQuery.Notes + + suspend fun createNote( + courseId: String, + objectId: String, + objectType: String, + userText: String?, + notebookType: String?, + highlightData: NoteHighlightedData? = null + ) + + suspend fun updateNote( + id: String, + userText: String?, + notebookType: String?, + highlightData: NoteHighlightedData? = null + ) + + suspend fun deleteNote(noteId: String) +} + +class RedwoodApiManagerImpl @Inject constructor( + @RedwoodApolloClient private val redwoodClient: ApolloClient +) : RedwoodApiManager { + override suspend fun getNotes( + filter: NoteFilterInput?, + firstN: Int?, + lastN: Int?, + after: String?, + before: String?, + orderBy: OrderByInput?, + forceNetwork: Boolean ): QueryNotesQuery.Notes { val query = QueryNotesQuery( filter = Optional.presentIfNotNull(filter), @@ -109,19 +140,19 @@ class RedwoodApiManager @Inject constructor( orderBy = Optional.presentIfNotNull(orderBy), ) val result = redwoodClient - .enqueueQuery(query) + .enqueueQuery(query, forceNetwork) .dataAssertNoErrors.notes return result } - suspend fun createNote( + override suspend fun createNote( courseId: String, objectId: String, objectType: String, userText: String?, notebookType: String?, - highlightData: NoteHighlightedData? = null + highlightData: NoteHighlightedData? ) { val reaction = if (notebookType == null) { Optional.absent() @@ -144,11 +175,11 @@ class RedwoodApiManager @Inject constructor( .dataAssertNoErrors } - suspend fun updateNote( + override suspend fun updateNote( id: String, userText: String?, notebookType: String?, - highlightData: NoteHighlightedData? = null + highlightData: NoteHighlightedData? ) { val reaction = if (notebookType == null) { Optional.absent() @@ -170,7 +201,7 @@ class RedwoodApiManager @Inject constructor( .dataAssertNoErrors } - suspend fun deleteNote(noteId: String) { + override suspend fun deleteNote(noteId: String) { val mutation = DeleteNoteMutation(noteId) redwoodClient diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/SubAssignmentSubmission.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/SubAssignmentSubmission.kt index 69424839bd..73951148cc 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/SubAssignmentSubmission.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/SubAssignmentSubmission.kt @@ -19,6 +19,7 @@ package com.instructure.canvasapi2.models import android.os.Parcelable import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize +import java.util.Date @Parcelize data class SubAssignmentSubmission( @@ -42,5 +43,7 @@ data class SubAssignmentSubmission( @SerializedName("user_id") val userId: Long = 0, @SerializedName("grade_matches_current_submission") - val isGradeMatchesCurrentSubmission: Boolean = false + val isGradeMatchesCurrentSubmission: Boolean = false, + @SerializedName("submitted_at") + val submittedAt: Date? = null ) : Parcelable diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/APIHelper.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/APIHelper.kt index 6b3425a4ef..2fdfb80f88 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/APIHelper.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/APIHelper.kt @@ -229,4 +229,57 @@ object APIHelper { } } + /** + * Extract shard ID from Canvas access token if it contains one + * Canvas tokens can be in the format: shardId~token + * + * @param token the access token to parse + * @return the shard ID if present, null otherwise + */ + fun getShardIdFromToken(token: String): String? { + return if (token.contains("~")) { + token.substringBefore("~") + } else { + null + } + } + + /** + * Create a global user ID from a shard ID and user ID, then expand it + * i.e., converts shardId "7053" and userId 2848 to 70530000000002848 + * + * @param shardId the shard ID + * @param userId the user ID + * @return the expanded global user ID as a Long + */ + fun createGlobalUserId(shardId: String, userId: Long): Long { + val tildeId = "$shardId~$userId" + return expandTildeId(tildeId).toLongOrNull() + ?: throw IllegalArgumentException("Invalid tilde ID: $tildeId") + } + + /** + * Get the appropriate user ID for a course, converting to global user ID if the course is on a different shard + * + * @param courseId the course ID + * @param userId the user ID + * @param shardIds map of course IDs to shard IDs + * @param accessToken the access token to extract the user's shard ID from + * @return the user ID to use (either original or global) + */ + fun getUserIdForCourse( + courseId: Long, + userId: Long, + shardIds: Map, + accessToken: String + ): Long { + val courseShardId = shardIds[courseId] + val tokenShardId = getShardIdFromToken(accessToken) + + return if (courseShardId != null && tokenShardId != null && courseShardId != tokenShardId) { + createGlobalUserId(tokenShardId, userId) + } else { + userId + } + } } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/Analytics.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/Analytics.kt index de6f34fd39..dec73c3c12 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/Analytics.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/Analytics.kt @@ -171,6 +171,12 @@ object AnalyticsEventConstants { const val WIDGET_SINGLE_GRADE_WIDGET_DELETED = "widget_single_grade_deleted" const val WIDGET_SINGLE_GRADE_OPEN_ITEM_ACTION = "widget_single_grade_open_item_action" const val WIDGET_SINGLE_GRADE_OPEN_APP_ACTION = "widget_single_grade_open_app_action" + + /* To Do List */ + const val TODO_ITEM_MARKED_DONE = "todo_item_marked_done" + const val TODO_ITEM_MARKED_UNDONE = "todo_item_marked_undone" + const val TODO_LIST_LOADED_DEFAULT_FILTER = "todo_list_loaded_default_filter" + const val TODO_LIST_LOADED_CUSTOM_FILTER = "todo_list_loaded_custom_filter" } /** @@ -190,4 +196,13 @@ object AnalyticsParamConstants { const val MEDIA_SOURCE = "media_source" const val MEDIA_TYPE = "media_type" const val ATTEMPT = "attempt" + const val RETRY = "retry" + + //todo filters + const val FILTER_PERSONAL_TODOS = "filter_personal_todos" + const val FILTER_CALENDAR_EVENTS = "filter_calendar_events" + const val FILTER_SHOW_COMPLETED = "filter_show_completed" + const val FILTER_FAVOURITE_COURSES = "filter_favourite_courses" + const val FILTER_SELECTED_DATE_RANGE_PAST = "filter_selected_date_range_past" + const val FILTER_SELECTED_DATE_RANGE_FUTURE = "filter_selected_date_range_future" } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt index 539c320191..128c196f1a 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt @@ -97,6 +97,8 @@ object ApiPrefs : PrefManager(PREFERENCE_FILE_NAME) { var overrideDomains: MutableMap = mutableMapOf() + var shardIds: MutableMap = mutableMapOf() + val fullDomain: String get() = if (isMasquerading || isStudentView) { when { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ModelExtensions.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ModelExtensions.kt index de3653e37d..b1fc938f36 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ModelExtensions.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ModelExtensions.kt @@ -38,6 +38,14 @@ import java.util.regex.Pattern private const val WORKFLOW_STATE_DELETED = "deleted" +/** + * Epsilon tolerance for floating-point comparisons in grade calculations. + * This accounts for precision loss during percentage/score conversions, + * particularly for low-point assignments (e.g., 1-point assignments where + * 90% = 0.9 may become 0.8999999... after float/double conversions). + */ +private const val GRADING_COMPARISON_EPSILON = 0.0000001 + fun Assignment.SubmissionType.prettyPrint(context: Context): String = Assignment.submissionTypeToPrettyPrintString(this, context) ?: "" @@ -207,7 +215,7 @@ fun convertScoreToLetterGrade(score: Double, maxScore: Double, gradingScheme: Li fun convertPercentScoreToLetterGrade(percentScore: Double, gradingScheme: List): String { if (gradingScheme.isEmpty()) return "" - val grade = gradingScheme.firstOrNull { percentScore >= it.value } ?: gradingScheme.last() + val grade = gradingScheme.firstOrNull { percentScore >= (it.value - GRADING_COMPARISON_EPSILON) } ?: gradingScheme.last() return grade.name } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/RemoteConfigUtils.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/RemoteConfigUtils.kt index 76618fbdef..27d1890335 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/RemoteConfigUtils.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/RemoteConfigUtils.kt @@ -17,6 +17,8 @@ enum class RemoteConfigParam(val rc_name: String, val safeValueAsString: String) TEST_LONG("test_long", "42"), TEST_STRING("test_string", "hey there"), SPEEDGRADER_V2("speedgrader_v2", "true"), + TODO_REDESIGN("todo_redesign", "true"), + DASHBOARD_REDESIGN("dashboard_redesign", "false") } /** diff --git a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/unit/APIHelperTest.kt b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/unit/APIHelperTest.kt index 6a468643aa..663a5d4fba 100644 --- a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/unit/APIHelperTest.kt +++ b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/unit/APIHelperTest.kt @@ -204,4 +204,109 @@ class APIHelperTest { assertEquals(expected, APIHelper.expandTildeId(actual)) } + + @Test + fun getShardIdFromToken_withShard() { + val token = "7053~abcdef1234567890" + val expected = "7053" + + assertEquals(expected, APIHelper.getShardIdFromToken(token)) + } + + @Test + fun getShardIdFromToken_withoutShard() { + val token = "abcdef1234567890" + + assertEquals(null, APIHelper.getShardIdFromToken(token)) + } + + @Test + fun getShardIdFromToken_emptyToken() { + val token = "" + + assertEquals(null, APIHelper.getShardIdFromToken(token)) + } + + @Test + fun createGlobalUserId() { + val shardId = "7053" + val userId = 2848L + val expected = 70530000000002848L + + assertEquals(expected, APIHelper.createGlobalUserId(shardId, userId)) + } + + @Test + fun createGlobalUserId_largerIds() { + val shardId = "12345" + val userId = 6789L + val expected = 123450000000006789L + + assertEquals(expected, APIHelper.createGlobalUserId(shardId, userId)) + } + + @Test + fun createGlobalUserId_shortShard() { + val shardId = "3" + val userId = 12771174L + val expected = 30000012771174L + + assertEquals(expected, APIHelper.createGlobalUserId(shardId, userId)) + } + + @Test + fun getUserIdForCourse_sameShard() { + val courseId = 1234L + val userId = 5678L + val shardIds = mapOf(courseId to "7053") + val accessToken = "7053~abcdef1234567890" + + // Should return original userId since both are on same shard + assertEquals(userId, APIHelper.getUserIdForCourse(courseId, userId, shardIds, accessToken)) + } + + @Test + fun getUserIdForCourse_differentShard() { + val courseId = 1234L + val userId = 5678L + val shardIds = mapOf(courseId to "7053") + val accessToken = "8000~abcdef1234567890" + + // Should return global user ID since course is on different shard + val expected = 80000000000005678L + assertEquals(expected, APIHelper.getUserIdForCourse(courseId, userId, shardIds, accessToken)) + } + + @Test + fun getUserIdForCourse_noShardId() { + val courseId = 1234L + val userId = 5678L + val shardIds = emptyMap() + val accessToken = "7053~abcdef1234567890" + + // Should return original userId since course has no shard ID + assertEquals(userId, APIHelper.getUserIdForCourse(courseId, userId, shardIds, accessToken)) + } + + @Test + fun getUserIdForCourse_noTokenShard() { + val courseId = 1234L + val userId = 5678L + val shardIds = mapOf(courseId to "7053") + val accessToken = "abcdef1234567890" + + // Should return original userId since token has no shard ID + assertEquals(userId, APIHelper.getUserIdForCourse(courseId, userId, shardIds, accessToken)) + } + + @Test + fun getUserIdForCourse_nullShardId() { + val courseId = 1234L + val userId = 5678L + val shardIds = mapOf(courseId to null) + val accessToken = "7053~abcdef1234567890" + + // Should return original userId since course shard ID is null + assertEquals(userId, APIHelper.getUserIdForCourse(courseId, userId, shardIds, accessToken)) + } } diff --git a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/ModelExtensionsTest.kt b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/ModelExtensionsTest.kt index a1994f6c55..c89b28d9a0 100644 --- a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/ModelExtensionsTest.kt +++ b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/ModelExtensionsTest.kt @@ -115,6 +115,66 @@ class ModelExtensionsTest { assertEquals("F", result) } + @Test + fun `Score to letter grade handles floating point precision for 1 point assignment with A grade`() { + val standardGradingScheme = listOf( + GradingSchemeRow("A", 0.9), + GradingSchemeRow("B", 0.8), + GradingSchemeRow("C", 0.7), + GradingSchemeRow("D", 0.6), + GradingSchemeRow("F", 0.0) + ) + val result = convertScoreToLetterGrade(0.9, 1.0, standardGradingScheme) + + assertEquals("A", result) + } + + @Test + fun `Score to letter grade handles floating point precision for 1 point assignment with B grade`() { + val standardGradingScheme = listOf( + GradingSchemeRow("A", 0.9), + GradingSchemeRow("B", 0.8), + GradingSchemeRow("C", 0.7), + GradingSchemeRow("D", 0.6), + GradingSchemeRow("F", 0.0) + ) + val result = convertScoreToLetterGrade(0.8, 1.0, standardGradingScheme) + + assertEquals("B", result) + } + + @Test + fun `Score to letter grade handles conversion from percentage to score for 1 point assignment`() { + val standardGradingScheme = listOf( + GradingSchemeRow("A", 0.9), + GradingSchemeRow("B", 0.8), + GradingSchemeRow("C", 0.7), + GradingSchemeRow("D", 0.6), + GradingSchemeRow("F", 0.0) + ) + val percentage = 90.0f + val pointsPossible = 1.0 + val score = (percentage / 100) * pointsPossible + val result = convertScoreToLetterGrade(score, pointsPossible, standardGradingScheme) + + assertEquals("A", result) + } + + @Test + fun `Score to letter grade handles exact threshold values`() { + val standardGradingScheme = listOf( + GradingSchemeRow("A", 0.9), + GradingSchemeRow("B", 0.8), + GradingSchemeRow("C", 0.7), + GradingSchemeRow("D", 0.6), + GradingSchemeRow("F", 0.0) + ) + assertEquals("A", convertScoreToLetterGrade(90.0, 100.0, standardGradingScheme)) + assertEquals("B", convertScoreToLetterGrade(80.0, 100.0, standardGradingScheme)) + assertEquals("C", convertScoreToLetterGrade(70.0, 100.0, standardGradingScheme)) + assertEquals("D", convertScoreToLetterGrade(60.0, 100.0, standardGradingScheme)) + } + @Test fun `Counts matching custom status submissions`() { val list = listOf( diff --git a/libs/horizon/flank.yml b/libs/horizon/flank.yml new file mode 100644 index 0000000000..175b54a6c1 --- /dev/null +++ b/libs/horizon/flank.yml @@ -0,0 +1,25 @@ +gcloud: + project: delta-essence-114723 +# Use the next two lines to run locally +# app: ../apps/student/build/outputs/apk/qa/debug/student-qa-debug.apk +# test: ./build/outputs/apk/androidTest/debug/horizon-debug-androidTest.apk + app: ./apps/student/build/outputs/apk/qa/debug/student-qa-debug.apk + test: ./libs/horizon/build/outputs/apk/androidTest/debug/horizon-debug-androidTest.apk + results-bucket: android-student + auto-google-login: true + use-orchestrator: true + performance-metrics: false + record-video: true + timeout: 30m + test-targets: + - package com.instructure.horizon.ui + - notAnnotation com.instructure.canvas.espresso.annotations.E2E, com.instructure.canvas.espresso.annotations.Stub, com.instructure.canvas.espresso.annotations.FlakyE2E, com.instructure.canvas.espresso.annotations.KnownBug, com.instructure.canvas.espresso.annotations.OfflineE2E + device: + - model: Pixel2.arm + version: 29 + locale: en_US + orientation: portrait + +flank: + testShards: 10 + testRuns: 1 diff --git a/libs/horizon/flank_interaction.yml b/libs/horizon/flank_interaction.yml new file mode 100644 index 0000000000..34a6dfd215 --- /dev/null +++ b/libs/horizon/flank_interaction.yml @@ -0,0 +1,25 @@ +gcloud: + project: delta-essence-114723 +# Use the next two lines to run locally +# app: ../apps/student/build/outputs/apk/qa/debug/student-qa-debug.apk +# test: ./build/outputs/apk/androidTest/debug/horizon-debug-androidTest.apk + app: ./apps/student/build/outputs/apk/qa/debug/student-qa-debug.apk + test: ./libs/horizon/build/outputs/apk/androidTest/debug/horizon-debug-androidTest.apk + results-bucket: android-student + auto-google-login: true + use-orchestrator: true + performance-metrics: false + record-video: true + timeout: 30m + test-targets: + - package com.instructure.horizon.interaction + - notAnnotation com.instructure.canvas.espresso.annotations.E2E, com.instructure.canvas.espresso.annotations.Stub, com.instructure.canvas.espresso.annotations.FlakyE2E, com.instructure.canvas.espresso.annotations.KnownBug, com.instructure.canvas.espresso.annotations.OfflineE2E + device: + - model: Pixel2.arm + version: 29 + locale: en_US + orientation: portrait + +flank: + testShards: 3 + testRuns: 1 diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt index 9997f8b0a6..5ee97ffdd6 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt @@ -2,11 +2,63 @@ package com.instructure.horizon.espresso import android.content.Intent import com.instructure.canvasapi2.LoginRouter +import com.instructure.canvasapi2.utils.pageview.PandataInfo +import com.instructure.pandautils.features.about.AboutRepository +import com.instructure.pandautils.features.assignments.details.AssignmentDetailsBehaviour +import com.instructure.pandautils.features.assignments.details.AssignmentDetailsColorProvider +import com.instructure.pandautils.features.assignments.details.AssignmentDetailsRepository +import com.instructure.pandautils.features.assignments.details.AssignmentDetailsRouter +import com.instructure.pandautils.features.assignments.details.AssignmentDetailsSubmissionHandler +import com.instructure.pandautils.features.assignments.list.AssignmentListBehavior +import com.instructure.pandautils.features.assignments.list.AssignmentListRepository +import com.instructure.pandautils.features.assignments.list.AssignmentListRouter +import com.instructure.pandautils.features.calendar.CalendarBehavior +import com.instructure.pandautils.features.calendar.CalendarRepository +import com.instructure.pandautils.features.calendar.CalendarRouter +import com.instructure.pandautils.features.calendarevent.createupdate.CreateUpdateEventRepository +import com.instructure.pandautils.features.calendarevent.createupdate.CreateUpdateEventViewModelBehavior +import com.instructure.pandautils.features.calendarevent.details.EventRouter +import com.instructure.pandautils.features.calendarevent.details.EventViewModelBehavior +import com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdateToDoRepository +import com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdateToDoViewModelBehavior +import com.instructure.pandautils.features.calendartodo.details.ToDoRouter +import com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior +import com.instructure.pandautils.features.dashboard.edit.EditDashboardRepository +import com.instructure.pandautils.features.dashboard.edit.EditDashboardRouter +import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter +import com.instructure.pandautils.features.discussion.details.DiscussionDetailsWebViewFragmentBehavior +import com.instructure.pandautils.features.discussion.router.DiscussionRouteHelperRepository +import com.instructure.pandautils.features.discussion.router.DiscussionRouter +import com.instructure.pandautils.features.elementary.grades.GradesRouter +import com.instructure.pandautils.features.elementary.homeroom.HomeroomRouter +import com.instructure.pandautils.features.elementary.importantdates.ImportantDatesRouter +import com.instructure.pandautils.features.elementary.resources.itemviewmodels.ResourcesRouter +import com.instructure.pandautils.features.elementary.schedule.ScheduleRouter +import com.instructure.pandautils.features.grades.GradesBehaviour +import com.instructure.pandautils.features.grades.GradesRepository +import com.instructure.pandautils.features.help.HelpDialogFragmentBehavior +import com.instructure.pandautils.features.help.HelpLinkFilter +import com.instructure.pandautils.features.inbox.compose.InboxComposeBehavior +import com.instructure.pandautils.features.inbox.compose.InboxComposeRepository +import com.instructure.pandautils.features.inbox.details.InboxDetailsBehavior +import com.instructure.pandautils.features.inbox.list.InboxRepository +import com.instructure.pandautils.features.inbox.list.InboxRouter +import com.instructure.pandautils.features.legal.LegalRouter +import com.instructure.pandautils.features.lti.LtiLaunchFragmentBehavior import com.instructure.pandautils.features.offline.sync.SyncRouter +import com.instructure.pandautils.features.settings.SettingsBehaviour +import com.instructure.pandautils.features.settings.SettingsRouter +import com.instructure.pandautils.features.shareextension.ShareExtensionRouter +import com.instructure.pandautils.features.smartsearch.SmartSearchRouter +import com.instructure.pandautils.features.speedgrader.SpeedGraderPostPolicyRouter import com.instructure.pandautils.features.speedgrader.content.SpeedGraderContentRouter import com.instructure.pandautils.features.speedgrader.grade.comments.SpeedGraderCommentsAttachmentRouter +import com.instructure.pandautils.features.todolist.ToDoListRouter +import com.instructure.pandautils.features.todolist.ToDoListViewModelBehavior +import com.instructure.pandautils.navigation.WebViewRouter import com.instructure.pandautils.receivers.alarm.AlarmReceiverNotificationHandler import com.instructure.pandautils.room.appdatabase.AppDatabase +import com.instructure.pandautils.utils.LogoutHelper import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -50,252 +102,262 @@ object HorizonTestModule { } @Provides - fun provideLogoutHelper(): com.instructure.pandautils.utils.LogoutHelper { + fun provideLogoutHelper(): LogoutHelper { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun providePandataInfoAppKey(): com.instructure.canvasapi2.utils.pageview.PandataInfo.AppKey { + fun providePandataInfoAppKey(): PandataInfo.AppKey { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideDiscussionRouteHelperRepository(): com.instructure.pandautils.features.discussion.router.DiscussionRouteHelperRepository { + fun provideDiscussionRouteHelperRepository(): DiscussionRouteHelperRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideAssignmentDetailsRouter(): com.instructure.pandautils.features.assignments.details.AssignmentDetailsRouter { + fun provideAssignmentDetailsRouter(): AssignmentDetailsRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideWebViewRouter(): com.instructure.pandautils.navigation.WebViewRouter { + fun provideWebViewRouter(): WebViewRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideAssignmentDetailsBehaviour(): com.instructure.pandautils.features.assignments.details.AssignmentDetailsBehaviour { + fun provideAssignmentDetailsBehaviour(): AssignmentDetailsBehaviour { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideAssignmentListRouter(): com.instructure.pandautils.features.assignments.list.AssignmentListRouter { + fun provideAssignmentListRouter(): AssignmentListRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideCalendarRouter(): com.instructure.pandautils.features.calendar.CalendarRouter { + fun provideCalendarRouter(): CalendarRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideEventRouter(): com.instructure.pandautils.features.calendarevent.details.EventRouter { + fun provideEventRouter(): EventRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideToDoRouter(): com.instructure.pandautils.features.calendartodo.details.ToDoRouter { + fun provideToDoRouter(): ToDoRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideEditDashboardRouter(): com.instructure.pandautils.features.dashboard.edit.EditDashboardRouter { + fun provideEditDashboardRouter(): EditDashboardRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideShareExtensionRouter(): com.instructure.pandautils.features.shareextension.ShareExtensionRouter { + fun provideShareExtensionRouter(): ShareExtensionRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideDashboardRouter(): com.instructure.pandautils.features.dashboard.notifications.DashboardRouter { + fun provideDashboardRouter(): DashboardRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideDiscussionRouter(): com.instructure.pandautils.features.discussion.router.DiscussionRouter { + fun provideDiscussionRouter(): DiscussionRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideDiscussionDetailsWebViewFragmentBehavior(): com.instructure.pandautils.features.discussion.details.DiscussionDetailsWebViewFragmentBehavior { + fun provideDiscussionDetailsWebViewFragmentBehavior(): DiscussionDetailsWebViewFragmentBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideGradesRouter(): com.instructure.pandautils.features.elementary.grades.GradesRouter { + fun provideGradesRouter(): GradesRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideHomeroomRouter(): com.instructure.pandautils.features.elementary.homeroom.HomeroomRouter { + fun provideHomeroomRouter(): HomeroomRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideImportantDatesRouter(): com.instructure.pandautils.features.elementary.importantdates.ImportantDatesRouter { + fun provideImportantDatesRouter(): ImportantDatesRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideResourcesRouter(): com.instructure.pandautils.features.elementary.resources.itemviewmodels.ResourcesRouter { + fun provideResourcesRouter(): ResourcesRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideScheduleRouter(): com.instructure.pandautils.features.elementary.schedule.ScheduleRouter { + fun provideScheduleRouter(): ScheduleRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideHelpDialogFragmentBehavior(): com.instructure.pandautils.features.help.HelpDialogFragmentBehavior { + fun provideHelpDialogFragmentBehavior(): HelpDialogFragmentBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideInboxRouter(): com.instructure.pandautils.features.inbox.list.InboxRouter { + fun provideInboxRouter(): InboxRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideLegalRouter(): com.instructure.pandautils.features.legal.LegalRouter { + fun provideLegalRouter(): LegalRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideLtiLaunchFragmentBehavior(): com.instructure.pandautils.features.lti.LtiLaunchFragmentBehavior { + fun provideLtiLaunchFragmentBehavior(): LtiLaunchFragmentBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideSettingsRouter(): com.instructure.pandautils.features.settings.SettingsRouter { + fun provideSettingsRouter(): SettingsRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideSmartSearchRouter(): com.instructure.pandautils.features.smartsearch.SmartSearchRouter { + fun provideSmartSearchRouter(): SmartSearchRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideAboutRepository(): com.instructure.pandautils.features.about.AboutRepository { + fun provideAboutRepository(): AboutRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideAssignmentDetailsRepository(): com.instructure.pandautils.features.assignments.details.AssignmentDetailsRepository { + fun provideAssignmentDetailsRepository(): AssignmentDetailsRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideAssignmentDetailsSubmissionHandler(): com.instructure.pandautils.features.assignments.details.AssignmentDetailsSubmissionHandler { + fun provideAssignmentDetailsSubmissionHandler(): AssignmentDetailsSubmissionHandler { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideAssignmentDetailsColorProvider(): com.instructure.pandautils.features.assignments.details.AssignmentDetailsColorProvider { + fun provideAssignmentDetailsColorProvider(): AssignmentDetailsColorProvider { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideAssignmentListRepository(): com.instructure.pandautils.features.assignments.list.AssignmentListRepository { + fun provideAssignmentListRepository(): AssignmentListRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideAssignmentListBehavior(): com.instructure.pandautils.features.assignments.list.AssignmentListBehavior { + fun provideAssignmentListBehavior(): AssignmentListBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideCalendarRepository(): com.instructure.pandautils.features.calendar.CalendarRepository { + fun provideCalendarRepository(): CalendarRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideCalendarBehavior(): com.instructure.pandautils.features.calendar.CalendarBehavior { + fun provideCalendarBehavior(): CalendarBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideCreateUpdateEventRepository(): com.instructure.pandautils.features.calendarevent.createupdate.CreateUpdateEventRepository { + fun provideCreateUpdateEventRepository(): CreateUpdateEventRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideCreateUpdateEventViewModelBehavior(): com.instructure.pandautils.features.calendarevent.createupdate.CreateUpdateEventViewModelBehavior { + fun provideCreateUpdateEventViewModelBehavior(): CreateUpdateEventViewModelBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideCreateUpdateToDoRepository(): com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdateToDoRepository { + fun provideCreateUpdateToDoRepository(): CreateUpdateToDoRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideCreateUpdateToDoViewModelBehavior(): com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdateToDoViewModelBehavior { + fun provideCreateUpdateToDoViewModelBehavior(): CreateUpdateToDoViewModelBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideEditDashboardRepository(): com.instructure.pandautils.features.dashboard.edit.EditDashboardRepository { + fun provideEditDashboardRepository(): EditDashboardRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideEventViewModelBehavior(): com.instructure.pandautils.features.calendarevent.details.EventViewModelBehavior { + fun provideEventViewModelBehavior(): EventViewModelBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideGradesBehaviour(): com.instructure.pandautils.features.grades.GradesBehaviour { + fun provideGradesBehaviour(): GradesBehaviour { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideGradesRepository(): com.instructure.pandautils.features.grades.GradesRepository { + fun provideGradesRepository(): GradesRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideHelpLinkFilter(): com.instructure.pandautils.features.help.HelpLinkFilter { + fun provideHelpLinkFilter(): HelpLinkFilter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideInboxComposeRepository(): com.instructure.pandautils.features.inbox.compose.InboxComposeRepository { + fun provideInboxComposeRepository(): InboxComposeRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideInboxComposeBehavior(): com.instructure.pandautils.features.inbox.compose.InboxComposeBehavior { + fun provideInboxComposeBehavior(): InboxComposeBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideInboxDetailsBehavior(): com.instructure.pandautils.features.inbox.details.InboxDetailsBehavior { + fun provideInboxDetailsBehavior(): InboxDetailsBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideInboxRepository(): com.instructure.pandautils.features.inbox.list.InboxRepository { + fun provideInboxRepository(): InboxRepository { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideSettingsBehaviour(): com.instructure.pandautils.features.settings.SettingsBehaviour { + fun provideSettingsBehaviour(): SettingsBehaviour { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideSpeedGraderPostPolicyRouter(): com.instructure.pandautils.features.speedgrader.SpeedGraderPostPolicyRouter { + fun provideSpeedGraderPostPolicyRouter(): SpeedGraderPostPolicyRouter { throw NotImplementedError("This is a test module. Implementation not required.") } @Provides - fun provideToDoViewModelBehavior(): com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior { + fun provideToDoViewModelBehavior(): ToDoViewModelBehavior { + throw NotImplementedError("This is a test module. Implementation not required.") + } + + @Provides + fun provideToDoListRouter(): ToDoListRouter { + throw NotImplementedError("This is a test module. Implementation not required.") + } + + @Provides + fun provideToDoListViewModelBehavior(): ToDoListViewModelBehavior { throw NotImplementedError("This is a test module. Implementation not required.") } } \ No newline at end of file diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/home/HorizonHomeInteractionTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/home/HorizonHomeInteractionTest.kt index d7d3d6549f..82edefc522 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/home/HorizonHomeInteractionTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/home/HorizonHomeInteractionTest.kt @@ -71,6 +71,7 @@ class HorizonHomeInteractionTest : HorizonTest() { val student = data.students.first() val token = data.tokenFor(student)!! tokenLogin(data.domain, token, student) + composeTestRule.waitForIdle() homePage.assertBottomNavigationVisible() } @@ -85,6 +86,7 @@ class HorizonHomeInteractionTest : HorizonTest() { val student = data.students.first() val token = data.tokenFor(student)!! tokenLogin(data.domain, token, student) + composeTestRule.waitForIdle() homePage.assertBottomNavigationVisible() homePage.clickLearnTab() diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notebook/NotebookInteractionTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notebook/NotebookInteractionTest.kt new file mode 100644 index 0000000000..a51001f019 --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notebook/NotebookInteractionTest.kt @@ -0,0 +1,125 @@ +/* + * 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.interaction.features.notebook + +import com.instructure.canvas.espresso.mockcanvas.MockCanvas +import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetHorizonCourseManager +import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetProgramsManager +import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetSkillsManager +import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetWidgetsManager +import com.instructure.canvas.espresso.mockcanvas.fakes.FakeRedwoodApiManager +import com.instructure.canvas.espresso.mockcanvas.init +import com.instructure.canvasapi2.di.graphql.GetCoursesModule +import com.instructure.canvasapi2.di.graphql.JourneyModule +import com.instructure.canvasapi2.di.graphql.RedwoodModule +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.GetSkillsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetWidgetsManager +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager +import com.instructure.horizon.espresso.HorizonTest +import com.instructure.horizon.pages.HorizonNotebookPage +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@HiltAndroidTest +@UninstallModules(GetCoursesModule::class, JourneyModule::class, RedwoodModule::class) +class NotebookInteractionTest : HorizonTest() { + private val fakeGetHorizonCourseManager = FakeGetHorizonCourseManager() + private val fakeGetProgramsManager = FakeGetProgramsManager() + private val fakeGetWidgetsManager = FakeGetWidgetsManager() + private val fakeGetSkillsManager = FakeGetSkillsManager() + private val fakeRedwoodApiManager = FakeRedwoodApiManager() + + @BindValue + @JvmField + val getProgramsManager: GetProgramsManager = fakeGetProgramsManager + + @BindValue + @JvmField + val getWidgetsManager: GetWidgetsManager = fakeGetWidgetsManager + + @BindValue + @JvmField + val getSkillsManager: GetSkillsManager = fakeGetSkillsManager + + @BindValue + @JvmField + val getCoursesManager: HorizonGetCoursesManager = fakeGetHorizonCourseManager + + @BindValue + @JvmField + val redwoodApiManager: RedwoodApiManager = fakeRedwoodApiManager + + private val notebookPage: HorizonNotebookPage by lazy { HorizonNotebookPage(composeTestRule) } + + @Before + fun setup() { + fakeRedwoodApiManager.reset() + } + @Test + fun testNoteIsDisplayed() = runTest { + val data = setupMockCanvasData() + val course = data.courses.values.first() + + fakeRedwoodApiManager.createNote( + courseId = course.id.toString(), + objectId = "test-object-id", + objectType = "Page", + userText = "User note", + notebookType = "Important", + highlightData = NoteHighlightedData( + selectedText = "Original note", + range = NoteHighlightedDataRange( + startOffset = 1, + endOffset = 4, + startContainer = "t", + endContainer = "t" + ), + textPosition = NoteHighlightedDataTextPosition( + start = 1, + end = 4 + ) + ) + ) + + dashboardPage.clickNotebookButton() + composeTestRule.waitForIdle() + + notebookPage.assertNoteDisplayed("Original note", "User note") + } + + private fun setupMockCanvasData(): MockCanvas { + val data = MockCanvas.init( + studentCount = 1, + teacherCount = 1, + courseCount = 1 + ) + val student = data.students.first() + val token = data.tokenFor(student)!! + tokenLogin(data.domain, token, student) + composeTestRule.waitForIdle() + return data + } +} diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notification/HorizonNotificationInteractionTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notification/HorizonNotificationInteractionTest.kt index 18a6af0362..b6d572f587 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notification/HorizonNotificationInteractionTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notification/HorizonNotificationInteractionTest.kt @@ -17,7 +17,9 @@ package com.instructure.horizon.interaction.features.notification import com.instructure.canvas.espresso.mockcanvas.MockCanvas -import com.instructure.canvas.espresso.mockcanvas.addAccountNotification +import com.instructure.canvas.espresso.mockcanvas.addAssignment +import com.instructure.canvas.espresso.mockcanvas.addSubmissionForAssignment +import com.instructure.canvas.espresso.mockcanvas.addSubmissionStreamItem import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetHorizonCourseManager import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetProgramsManager import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetSkillsManager @@ -70,9 +72,24 @@ class HorizonNotificationInteractionTest: HorizonTest() { val token = data.tokenFor(student)!! tokenLogin(data.domain, token, student) - val accountNotification = data.addAccountNotification() + val course = data.courses.values.first() + val assignment = data.addAssignment(course.id) + val submission = data.addSubmissionForAssignment( + assignment.id, + student.id, + type = "text", + body = "submission" + ) + val streamItem = data.addSubmissionStreamItem( + student, + course, + assignment, + submission, + grade = "A" + ) + dashboardPage.clickNotificationButton() - notificationsPage.assertNotificationItem(accountNotification.subject, "Announcement") + notificationsPage.assertNotificationItem(streamItem.title.orEmpty(), "Score changed") } } \ No newline at end of file diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonDashboardPage.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonDashboardPage.kt index dec33fa92c..523f8d91b6 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonDashboardPage.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonDashboardPage.kt @@ -18,6 +18,7 @@ package com.instructure.horizon.pages import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filter import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyDescendant import androidx.compose.ui.test.hasText @@ -34,13 +35,10 @@ class HorizonDashboardPage(private val composeTestRule: ComposeTestRule) { fun assertNotStartedProgramDisplayed(programName: String) { composeTestRule.onNodeWithText(programName, substring = true) .assertIsDisplayed() - - composeTestRule.onNodeWithText("Program details", useUnmergedTree = true) - .assertIsDisplayed() } fun clickProgramDetails(programName: String) { - composeTestRule.onNodeWithText("Program details", useUnmergedTree = true) + composeTestRule.onNodeWithText(programName, useUnmergedTree = true) .assertIsDisplayed() .performClick() } @@ -69,7 +67,8 @@ class HorizonDashboardPage(private val composeTestRule: ComposeTestRule) { if (progress != null) { courseCardParent.onChildren() - .filterToOne(hasAnyDescendant(hasText(progress.roundToInt().toString() + "%", substring = true))) + .filter(hasAnyDescendant(hasText(progress.roundToInt().toString() + "%", substring = true))) + .onFirst() .assertIsDisplayed() } diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonNotebookPage.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonNotebookPage.kt new file mode 100644 index 0000000000..5269f5c680 --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonNotebookPage.kt @@ -0,0 +1,202 @@ +/* + * 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.pages + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.test.platform.app.InstrumentationRegistry +import com.instructure.horizon.R + +class HorizonNotebookPage(private val composeTestRule: ComposeTestRule) { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + fun assertAddNoteScreenDisplayed() { + composeTestRule.onNodeWithText(context.getString(R.string.createNoteTitle)) + .assertIsDisplayed() + } + + fun assertEditNoteScreenDisplayed() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteTitle)) + .assertIsDisplayed() + } + + fun assertHighlightedTextDisplayed(text: String) { + composeTestRule.onNodeWithText(text) + .assertIsDisplayed() + } + + fun assertSaveButtonEnabled() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteSaveButtonLabel)) + .assertIsEnabled() + } + + fun assertSaveButtonDisabled() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteSaveButtonLabel)) + .assertIsNotEnabled() + } + + fun assertCancelButtonDisplayed() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteCancelButtonLabel)) + .assertIsDisplayed() + } + + fun assertDeleteButtonDisplayed() { + composeTestRule.onNodeWithText(context.getString(R.string.deleteNoteLabel)) + .performScrollTo() + .assertIsDisplayed() + } + + fun assertDeleteButtonNotDisplayed() { + composeTestRule.onNode(hasText(context.getString(R.string.deleteNoteLabel))) + .assertDoesNotExist() + } + + fun assertLastModifiedDateDisplayed(date: String) { + composeTestRule.onNodeWithText(date) + .performScrollTo() + .assertIsDisplayed() + } + + fun assertLoadingDisplayed() { + composeTestRule.onNodeWithTag("LoadingSpinner") + .assertIsDisplayed() + } + + fun assertDeleteConfirmationDialogDisplayed() { + composeTestRule.onNodeWithText(context.getString(R.string.deleteNoteConfirmationTitle)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.deleteNoteConfirmationMessage)) + .assertIsDisplayed() + } + + fun assertExitConfirmationDialogDisplayed() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteExitConfirmationTitle)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.editNoteExitConfirmationMessage)) + .assertIsDisplayed() + } + + fun assertTypeSelected(type: String) { + composeTestRule.onNodeWithText(type) + .assertIsDisplayed() + } + + fun assertTextAreaPlaceholderDisplayed() { + composeTestRule.onNodeWithText(context.getString(R.string.addNoteAddANoteLabel)) + .assertIsDisplayed() + } + + fun assertUserCommentDisplayed(comment: String) { + composeTestRule.onNode(hasText(comment)) + .assertExists() + } + + fun clickSaveButton() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteSaveButtonLabel)) + .performClick() + } + + fun clickCancelButton() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteCancelButtonLabel)) + .performClick() + } + + fun clickDeleteButton() { + composeTestRule.onNodeWithText(context.getString(R.string.deleteNoteLabel)) + .performScrollTo() + .performClick() + } + + fun clickDeleteConfirmationButton() { + composeTestRule.onNodeWithText(context.getString(R.string.deleteNoteConfirmationDeleteLabel)) + .performClick() + } + + fun clickDeleteCancelButton() { + composeTestRule.onNodeWithText(context.getString(R.string.deleteNoteConfirmationCancelLabel)) + .performClick() + } + + fun clickExitConfirmationButton() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteExitConfirmationExitButtonLabel)) + .performClick() + } + + fun clickExitCancelButton() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteExitConfirmationCancelButtonLabel)) + .performClick() + } + + fun enterUserComment(comment: String) { + composeTestRule.onNodeWithText(context.getString(R.string.addNoteAddANoteLabel)) + .performClick() + .performTextInput(comment) + } + + fun clearUserComment() { + composeTestRule.onNode(hasText(context.getString(R.string.addNoteAddANoteLabel)).not()) + .performClick() + .performTextClearance() + } + + fun selectType(type: String) { + composeTestRule.onNodeWithText(type) + .performClick() + } + + fun clickAddNoteButton() { + composeTestRule.onNodeWithText(context.getString(R.string.editNoteSaveButtonLabel)) + .performClick() + } + + fun clickNote(noteText: String) { + composeTestRule.onNodeWithText(noteText, substring = true) + .performClick() + } + + fun assertNoteDisplayed(highlightText: String, noteText: String? = null) { + composeTestRule.onNodeWithText(highlightText, substring = true) + .assertIsDisplayed() + + noteText?.let { + composeTestRule.onNodeWithText(noteText, substring = true) + .assertIsDisplayed() + } + } + + fun assertNoteNotDisplayed(noteText: String) { + composeTestRule.onNode(hasText(noteText, substring = true)) + .assertDoesNotExist() + } + + fun clearNoteText() { + composeTestRule.onNode( + hasText(context.getString(R.string.addNoteAddANoteLabel)).not() + ) + .performClick() + .performTextClearance() + } +} diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonNotificationPage.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonNotificationPage.kt index 772bf5259b..4d5e2402a7 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonNotificationPage.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/pages/HorizonNotificationPage.kt @@ -28,7 +28,7 @@ class HorizonNotificationPage(private val composeTestRule: ComposeTestRule) { fun assertNotificationItem(title: String, label: String) { composeTestRule.onNode( hasAnyChild(hasText(label)).and( - hasAnyChild(hasText(title)) + hasAnyChild(hasText(title, true)) ) ).assertIsDisplayed().onChild().assertHasClickAction() } diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/course/HorizonDashboardCourseSectionUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/course/HorizonDashboardCourseSectionUiTest.kt index e48e6049f2..699545f139 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/course/HorizonDashboardCourseSectionUiTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/course/HorizonDashboardCourseSectionUiTest.kt @@ -22,8 +22,10 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performScrollTo import androidx.navigation.compose.rememberNavController import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardButtonRoute +import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardHeaderState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardItemState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardState import com.instructure.horizon.features.dashboard.widget.course.DashboardCourseSection @@ -32,6 +34,7 @@ import com.instructure.horizon.features.dashboard.widget.course.card.CardClickAc import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardModuleItemState import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardParentProgramState import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardState +import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.model.LearningObjectType import org.junit.Rule import org.junit.Test @@ -50,6 +53,11 @@ class HorizonDashboardCourseSectionUiTest { programs = DashboardPaginatedWidgetCardState( listOf( DashboardPaginatedWidgetCardItemState( + headerState = DashboardPaginatedWidgetCardHeaderState( + label = "Program", + color = HorizonColors.Surface.institution().copy(0.1f), + iconRes = R.drawable.book_2 + ), title = "Program 1", route = DashboardPaginatedWidgetCardButtonRoute.HomeRoute("") ) diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/announcement/DashboardAnnouncementBannerWidgetUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/announcement/DashboardAnnouncementBannerWidgetUiTest.kt index d700edea7e..323e892623 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/announcement/DashboardAnnouncementBannerWidgetUiTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/announcement/DashboardAnnouncementBannerWidgetUiTest.kt @@ -16,9 +16,10 @@ */ package com.instructure.horizon.ui.features.dashboard.widget.announcement -import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.navigation.compose.rememberNavController @@ -27,15 +28,16 @@ import androidx.test.platform.app.InstrumentationRegistry import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardButtonRoute -import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardChipState +import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardHeaderState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardItemState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardState import com.instructure.horizon.features.dashboard.widget.announcement.DashboardAnnouncementBannerSection import com.instructure.horizon.features.dashboard.widget.announcement.DashboardAnnouncementBannerUiState -import com.instructure.horizon.horizonui.molecules.StatusChipColor +import com.instructure.horizon.horizonui.foundation.HorizonColors import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.util.Calendar import java.util.Date @RunWith(AndroidJUnit4::class) @@ -77,9 +79,8 @@ class DashboardAnnouncementBannerWidgetUiTest { val errorMessage = context.getString(R.string.dashboardAnnouncementBannerErrorMessage) composeTestRule.onNodeWithText(errorMessage, substring = true).assertIsDisplayed() - composeTestRule.onNodeWithText("Refresh") + composeTestRule.onNodeWithText("Refresh", useUnmergedTree = true) .assertIsDisplayed() - .assertHasClickAction() .performClick() assert(refreshCalled) { "Refresh callback should be called when retry button is clicked" } @@ -88,9 +89,10 @@ class DashboardAnnouncementBannerWidgetUiTest { @Test fun testSuccessStateWithSingleAnnouncementDisplaysCorrectly() { val testAnnouncement = DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.campaign ), title = "Important Announcement", source = "Course Name", @@ -122,10 +124,10 @@ class DashboardAnnouncementBannerWidgetUiTest { fun testSuccessStateWithMultipleAnnouncementsDisplaysAllItems() { val announcements = listOf( DashboardPaginatedWidgetCardItemState( - pageState = "1 of 2", - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.campaign ), title = "First Announcement", source = "Course 1", @@ -133,10 +135,10 @@ class DashboardAnnouncementBannerWidgetUiTest { route = DashboardPaginatedWidgetCardButtonRoute.MainRoute("") ), DashboardPaginatedWidgetCardItemState( - pageState = "2 of 2", - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.campaign ), title = "Second Announcement", source = "Course 2", @@ -156,19 +158,18 @@ class DashboardAnnouncementBannerWidgetUiTest { } composeTestRule.onNodeWithText("First Announcement").assertIsDisplayed() - composeTestRule.onNodeWithText( - context.getString(R.string.dashboardAnnouncementBannerFrom, "Course 1") - ).assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.dashboardAnnouncementBannerFrom, "Course 1")).assertIsDisplayed() - composeTestRule.onNodeWithText("1 of 2", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onAllNodesWithText("1 of 2").onFirst().assertIsDisplayed() } @Test fun testSuccessStateWithGlobalAnnouncementWithoutSource() { val testAnnouncement = DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.campaign ), title = "Global Announcement", source = null, @@ -195,9 +196,10 @@ class DashboardAnnouncementBannerWidgetUiTest { @Test fun testSuccessStateAnnouncementIsClickable() { val testAnnouncement = DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.campaign ), title = "Test Announcement", source = "Test Course", @@ -222,11 +224,13 @@ class DashboardAnnouncementBannerWidgetUiTest { @Test fun testSuccessStateDisplaysDateCorrectly() { - val testDate = Date(1704067200000L) + val testDate = Calendar.getInstance().apply { set(2024, 0, 1) }.time + val testAnnouncement = DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.campaign ), title = "Dated Announcement", source = "Test Course", @@ -250,9 +254,10 @@ class DashboardAnnouncementBannerWidgetUiTest { @Test fun testSuccessStateWithAnnouncementWithoutDate() { val testAnnouncement = DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.campaign ), title = "No Date Announcement", source = "Test Course", @@ -277,9 +282,10 @@ class DashboardAnnouncementBannerWidgetUiTest { fun testSuccessStateWithLongAnnouncementTitle() { val longTitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. This is a very long announcement title that should be displayed properly in the widget." val testAnnouncement = DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.campaign ), title = longTitle, source = "Test Course", diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/course/list/DashboardCourseListScreenUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/course/list/DashboardCourseListScreenUiTest.kt new file mode 100644 index 0000000000..7847f2a621 --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/course/list/DashboardCourseListScreenUiTest.kt @@ -0,0 +1,444 @@ +/* + * 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.ui.features.dashboard.widget.course.list + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onChild +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.horizon.features.dashboard.widget.course.list.DashboardCourseListCourseState +import com.instructure.horizon.features.dashboard.widget.course.list.DashboardCourseListFilterOption +import com.instructure.horizon.features.dashboard.widget.course.list.DashboardCourseListParentProgramState +import com.instructure.horizon.features.dashboard.widget.course.list.DashboardCourseListScreen +import com.instructure.horizon.features.dashboard.widget.course.list.DashboardCourseListUiState +import com.instructure.horizon.horizonui.platform.LoadingState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DashboardCourseListScreenUiTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun createTestCourses(count: Int): List { + return (1..count).map { index -> + DashboardCourseListCourseState( + parentPrograms = emptyList(), + name = "Course $index", + courseId = index.toLong(), + progress = (index * 10).toDouble() % 100 + ) + } + } + + @Test + fun testEmptyStateDisplaysEmptyMessage() { + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = emptyList() + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("Nothing here yet") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Adjust your filters to see more.") + .assertIsDisplayed() + } + + @Test + fun testCoursesAreDisplayed() { + val courses = listOf( + DashboardCourseListCourseState( + parentPrograms = emptyList(), + name = "Math 101", + courseId = 1L, + progress = 50.0 + ), + DashboardCourseListCourseState( + parentPrograms = emptyList(), + name = "Science 201", + courseId = 2L, + progress = 75.0 + ) + ) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("Math 101") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Science 201") + .assertIsDisplayed() + } + + @Test + fun testCourseProgressIsDisplayed() { + val courses = listOf( + DashboardCourseListCourseState( + parentPrograms = emptyList(), + name = "Math 101", + courseId = 1L, + progress = 50.0 + ) + ) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("50%", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testParentProgramsAreDisplayed() { + val courses = listOf( + DashboardCourseListCourseState( + parentPrograms = listOf( + DashboardCourseListParentProgramState( + programName = "Computer Science Degree", + programId = "cs-101" + ) + ), + name = "Math 101", + courseId = 1L, + progress = 50.0 + ) + ) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("Computer Science Degree", substring = true) + .assertIsDisplayed() + } + + @Test + fun testFilterOptionsAreDisplayed() { + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = createTestCourses(5), + visibleCourseCount = 3, + selectedFilterOption = DashboardCourseListFilterOption.All + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("All courses") + .assertIsDisplayed() + } + + @Test + fun testCourseCountIsDisplayed() { + val courses = createTestCourses(15) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("15") + .assertIsDisplayed() + } + + @Test + fun testShowMoreButtonIsDisplayedWhenMoreCoursesExist() { + val courses = createTestCourses(15) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithTag("collapsableContent") + .onChild() + .performScrollToNode(hasText("Show more")) + composeTestRule.onNodeWithText("Show more") + .assertIsDisplayed() + } + + @Test + fun testShowMoreButtonIsNotDisplayedWhenAllCoursesAreVisible() { + val courses = createTestCourses(3) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("Show more") + .assertDoesNotExist() + } + + @Test + fun testShowMoreButtonIsNotDisplayedWhenLessCoursesExist() { + val courses = createTestCourses(2) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("Show more") + .assertDoesNotExist() + } + + @Test + fun testShowMoreButtonClickCallsCallback() { + var showMoreCalled = false + val courses = createTestCourses(15) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3, + onShowMoreCourses = { showMoreCalled = true } + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithTag("collapsableContent") + .onChild() + .performScrollToNode(hasText("Show more")) + composeTestRule.onNodeWithText("Show more") + .assertIsDisplayed() + .performClick() + + assert(showMoreCalled) { "Show more callback should be called when button is clicked" } + } + + @Test + fun testOnlyVisibleCoursesAreDisplayed() { + val courses = createTestCourses(15) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithTag("collapsableContent") + .onChild() + .performScrollToNode(hasText("Course 1")) + composeTestRule.onNodeWithText("Course 1") + .assertIsDisplayed() + + composeTestRule.onNodeWithTag("collapsableContent") + .onChild() + .performScrollToNode(hasText("Course 2")) + composeTestRule.onNodeWithText("Course 2") + .assertIsDisplayed() + + composeTestRule.onNodeWithTag("collapsableContent") + .onChild() + .performScrollToNode(hasText("Course 3")) + composeTestRule.onNodeWithText("Course 3") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Course 4") + .assertDoesNotExist() + + composeTestRule.onNodeWithText("Course 5") + .assertDoesNotExist() + } + + @Test + fun testMoreCoursesDisplayedAfterIncreasingVisibleCount() { + val courses = createTestCourses(15) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 6 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithTag("collapsableContent") + .onChild() + .performScrollToNode(hasText("Course 1")) + composeTestRule.onNodeWithText("Course 1") + .assertIsDisplayed() + + composeTestRule.onNodeWithTag("collapsableContent") + .onChild() + .performScrollToNode(hasText("Course 6")) + composeTestRule.onNodeWithText("Course 6") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Course 7") + .assertDoesNotExist() + } + + @Test + fun testShowMoreButtonDisappearsWhenAllCoursesAreShown() { + val courses = createTestCourses(5) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 5 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("Course 1") + .assertIsDisplayed() + + composeTestRule.onNodeWithTag("collapsableContent") + .onChild() + .performScrollToNode(hasText("Course 5")) + composeTestRule.onNodeWithText("Course 5") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Show more") + .assertDoesNotExist() + } + + @Test + fun testTitleIsDisplayed() { + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = createTestCourses(5), + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("All courses") + .assertIsDisplayed() + } + + @Test + fun testMultipleParentProgramsAreDisplayed() { + val courses = listOf( + DashboardCourseListCourseState( + parentPrograms = listOf( + DashboardCourseListParentProgramState( + programName = "Program A", + programId = "prog-a" + ), + DashboardCourseListParentProgramState( + programName = "Program B", + programId = "prog-b" + ) + ), + name = "Math 101", + courseId = 1L, + progress = 50.0 + ) + ) + + val state = DashboardCourseListUiState( + loadingState = LoadingState(isLoading = false), + courses = courses, + visibleCourseCount = 3 + ) + + composeTestRule.setContent { + val navController = rememberNavController() + DashboardCourseListScreen(state, navController) + } + + composeTestRule.onNodeWithText("Program A", substring = true) + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Program B", substring = true) + .assertIsDisplayed() + } +} diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/myprogress/DashboardMyProgressWidgetUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/myprogress/DashboardMyProgressWidgetUiTest.kt index db34581605..915dcc5e51 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/myprogress/DashboardMyProgressWidgetUiTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/myprogress/DashboardMyProgressWidgetUiTest.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.dashboard.widget.myprogress.DashboardMyProgressSection import com.instructure.horizon.features.dashboard.widget.myprogress.DashboardMyProgressUiState import com.instructure.horizon.features.dashboard.widget.myprogress.card.DashboardMyProgressCardState @@ -42,7 +43,7 @@ class DashboardMyProgressWidgetUiTest { ) composeTestRule.setContent { - DashboardMyProgressSection(state) + DashboardMyProgressSection(state, DashboardWidgetPageState.Empty) } composeTestRule.waitForIdle() @@ -60,7 +61,7 @@ class DashboardMyProgressWidgetUiTest { ) composeTestRule.setContent { - DashboardMyProgressSection(state) + DashboardMyProgressSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("Activities").assertIsDisplayed() @@ -85,7 +86,7 @@ class DashboardMyProgressWidgetUiTest { ) composeTestRule.setContent { - DashboardMyProgressSection(state) + DashboardMyProgressSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("Activities").assertIsDisplayed() @@ -105,7 +106,7 @@ class DashboardMyProgressWidgetUiTest { ) composeTestRule.setContent { - DashboardMyProgressSection(state) + DashboardMyProgressSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("This widget will update once data becomes available.") diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidgetUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidgetUiTest.kt index dbc99d6a41..d26a5918ec 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidgetUiTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidgetUiTest.kt @@ -9,6 +9,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.dashboard.widget.skilloverview.DashboardSkillOverviewSection import com.instructure.horizon.features.dashboard.widget.skilloverview.DashboardSkillOverviewUiState import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardState @@ -30,7 +31,7 @@ class DashboardSkillOverviewWidgetUiTest { ) composeTestRule.setContent { - DashboardSkillOverviewSection(uiState, rememberNavController()) + DashboardSkillOverviewSection(uiState, DashboardWidgetPageState.Empty, rememberNavController()) } val title = context.getString(R.string.dashboardSkillOverviewTitle) @@ -45,7 +46,7 @@ class DashboardSkillOverviewWidgetUiTest { ) composeTestRule.setContent { - DashboardSkillOverviewSection(uiState, rememberNavController()) + DashboardSkillOverviewSection(uiState, DashboardWidgetPageState.Empty, rememberNavController()) } val title = context.getString(R.string.dashboardSkillOverviewTitle) @@ -66,7 +67,7 @@ class DashboardSkillOverviewWidgetUiTest { ) composeTestRule.setContent { - DashboardSkillOverviewSection(uiState, rememberNavController()) + DashboardSkillOverviewSection(uiState, DashboardWidgetPageState.Empty, rememberNavController()) } val retryLabel = context.getString(R.string.dashboardWidgetCardErrorRetry) @@ -83,7 +84,7 @@ class DashboardSkillOverviewWidgetUiTest { ) composeTestRule.setContent { - DashboardSkillOverviewSection(uiState, rememberNavController()) + DashboardSkillOverviewSection(uiState, DashboardWidgetPageState.Empty, rememberNavController()) } val title = context.getString(R.string.dashboardSkillOverviewTitle) @@ -101,7 +102,7 @@ class DashboardSkillOverviewWidgetUiTest { ) composeTestRule.setContent { - DashboardSkillOverviewSection(uiState, rememberNavController()) + DashboardSkillOverviewSection(uiState, DashboardWidgetPageState.Empty, rememberNavController()) } val title = context.getString(R.string.dashboardSkillOverviewTitle) @@ -120,7 +121,7 @@ class DashboardSkillOverviewWidgetUiTest { ) composeTestRule.setContent { - DashboardSkillOverviewSection(uiState, rememberNavController()) + DashboardSkillOverviewSection(uiState, DashboardWidgetPageState.Empty, rememberNavController()) } val title = context.getString(R.string.dashboardSkillOverviewTitle) @@ -139,7 +140,7 @@ class DashboardSkillOverviewWidgetUiTest { ) composeTestRule.setContent { - DashboardSkillOverviewSection(uiState, rememberNavController()) + DashboardSkillOverviewSection(uiState, DashboardWidgetPageState.Empty, rememberNavController()) } val title = context.getString(R.string.dashboardSkillOverviewTitle) @@ -158,7 +159,7 @@ class DashboardSkillOverviewWidgetUiTest { ) composeTestRule.setContent { - DashboardSkillOverviewSection(uiState, rememberNavController()) + DashboardSkillOverviewSection(uiState, DashboardWidgetPageState.Empty, rememberNavController()) } val title = context.getString(R.string.dashboardSkillOverviewTitle) diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/timespent/DashboardTimeSpentWidgetUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/timespent/DashboardTimeSpentWidgetUiTest.kt index a1929c52d4..403b3aaba8 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/timespent/DashboardTimeSpentWidgetUiTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/timespent/DashboardTimeSpentWidgetUiTest.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.dashboard.widget.timespent.DashboardTimeSpentSection import com.instructure.horizon.features.dashboard.widget.timespent.DashboardTimeSpentUiState import com.instructure.horizon.features.dashboard.widget.timespent.card.CourseOption @@ -43,7 +44,7 @@ class DashboardTimeSpentWidgetUiTest { ) composeTestRule.setContent { - DashboardTimeSpentSection(state) + DashboardTimeSpentSection(state, DashboardWidgetPageState.Empty) } composeTestRule.waitForIdle() @@ -61,7 +62,7 @@ class DashboardTimeSpentWidgetUiTest { ) composeTestRule.setContent { - DashboardTimeSpentSection(state) + DashboardTimeSpentSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("Time learning").assertIsDisplayed() @@ -90,7 +91,7 @@ class DashboardTimeSpentWidgetUiTest { ) composeTestRule.setContent { - DashboardTimeSpentSection(state) + DashboardTimeSpentSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("Time learning").assertIsDisplayed() @@ -116,7 +117,7 @@ class DashboardTimeSpentWidgetUiTest { ) composeTestRule.setContent { - DashboardTimeSpentSection(state) + DashboardTimeSpentSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("Time learning").assertIsDisplayed() @@ -143,7 +144,7 @@ class DashboardTimeSpentWidgetUiTest { ) composeTestRule.setContent { - DashboardTimeSpentSection(state) + DashboardTimeSpentSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("Time learning").assertIsDisplayed() @@ -172,7 +173,7 @@ class DashboardTimeSpentWidgetUiTest { ) composeTestRule.setContent { - DashboardTimeSpentSection(state) + DashboardTimeSpentSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("Time learning").assertIsDisplayed() @@ -195,7 +196,7 @@ class DashboardTimeSpentWidgetUiTest { ) composeTestRule.setContent { - DashboardTimeSpentSection(state) + DashboardTimeSpentSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("Time learning").assertIsDisplayed() @@ -219,7 +220,7 @@ class DashboardTimeSpentWidgetUiTest { ) composeTestRule.setContent { - DashboardTimeSpentSection(state) + DashboardTimeSpentSection(state, DashboardWidgetPageState.Empty) } composeTestRule.onNodeWithText("Time learning").assertIsDisplayed() @@ -240,23 +241,20 @@ class DashboardTimeSpentWidgetUiTest { cardState = DashboardTimeSpentCardState( hours = 0, minutes = 0, - courses = listOf( - CourseOption(id = 1L, name = "Course 1") - ) ) ) composeTestRule.setContent { - DashboardTimeSpentSection(state) + DashboardTimeSpentSection(state, DashboardWidgetPageState.Empty) } // Verify zero hours is not displayed - composeTestRule.onNodeWithText("0").assertDoesNotExist() + composeTestRule.onNodeWithText("0", useUnmergedTree = true).assertDoesNotExist() // Verify single course text is not displayed - composeTestRule.onNodeWithText("hours in your course").assertDoesNotExist() + composeTestRule.onNodeWithText("hours", useUnmergedTree = true).assertDoesNotExist() // Verify empty state message is displayed - composeTestRule.onNodeWithText("This widget will update once data becomes available.").assertIsDisplayed() + composeTestRule.onNodeWithText("This widget will update once data becomes available.", useUnmergedTree = true).assertIsDisplayed() } } diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/notebook/AddEditNoteScreenUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/notebook/AddEditNoteScreenUiTest.kt new file mode 100644 index 0000000000..f78471d0b9 --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/notebook/AddEditNoteScreenUiTest.kt @@ -0,0 +1,313 @@ +/* + * 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.ui.features.notebook + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.text.input.TextFieldValue +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.horizon.R +import com.instructure.horizon.features.notebook.addedit.AddEditNoteScreen +import com.instructure.horizon.features.notebook.addedit.AddEditNoteUiState +import com.instructure.horizon.features.notebook.common.model.NotebookType +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class AddEditNoteScreenUiTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun testAddNoteScreenDisplaysCorrectTitle() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState() + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(context.getString(R.string.createNoteTitle)) + .assertIsDisplayed() + } + + @Test + fun testEditNoteScreenDisplaysCorrectTitle() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createEditNoteState() + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(context.getString(R.string.editNoteTitle)) + .assertIsDisplayed() + } + + @Test + fun testSaveButtonIsDisabledWhenNoContentChange() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState(hasContentChange = false) + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(context.getString(R.string.editNoteSaveButtonLabel)) + .assertIsNotEnabled() + } + + @Test + fun testSaveButtonIsEnabledWhenContentChanged() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState(hasContentChange = true) + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(context.getString(R.string.editNoteSaveButtonLabel)) + .assertIsEnabled() + } + + @Test + fun testCancelButtonIsDisplayed() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState() + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(context.getString(R.string.editNoteCancelButtonLabel)) + .assertIsDisplayed() + } + + @Test + fun testHighlightedTextIsDisplayed() { + val highlightedText = "This is highlighted text for testing" + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState(highlightedText = highlightedText) + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(highlightedText) + .assertIsDisplayed() + } + + @Test + fun testTextAreaIsDisplayed() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState() + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(context.getString(R.string.addNoteAddANoteLabel)) + .assertIsDisplayed() + } + + @Test + fun testDeleteButtonIsDisplayedForEditMode() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createEditNoteState() + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(context.getString(R.string.deleteNoteLabel)) + .assertIsDisplayed() + } + + @Test + fun testDeleteButtonIsNotDisplayedForAddMode() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState() + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNode(hasText(context.getString(R.string.deleteNoteLabel))) + .assertDoesNotExist() + } + + @Test + fun testLastModifiedDateIsDisplayedForEditMode() { + val lastModifiedDate = "Updated 2 hours ago" + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createEditNoteState(lastModifiedDate = lastModifiedDate) + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(lastModifiedDate) + .assertIsDisplayed() + } + + @Test + fun testLoadingStateDisplaysSpinner() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState(isLoading = true) + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithTag("LoadingSpinner") + .assertIsDisplayed() + } + + @Test + fun testDeleteConfirmationDialogDisplaysWhenTriggered() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createEditNoteState(showDeleteConfirmationDialog = true) + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(context.getString(R.string.deleteNoteConfirmationMessage)) + .assertIsDisplayed() + } + + @Test + fun testExitConfirmationDialogDisplaysWhenTriggered() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState( + hasContentChange = true, + showExitConfirmationDialog = true + ) + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText(context.getString(R.string.editNoteExitConfirmationTitle)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.editNoteExitConfirmationMessage)) + .assertIsDisplayed() + } + + @Test + fun testTypeSelectionDisplaysImportantOption() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState(type = NotebookType.Important) + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText("Important", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testTypeSelectionDisplaysConfusingOption() { + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + val state = createAddNoteState(type = NotebookType.Confusing) + AddEditNoteScreen(navController, state) { _, _ -> } + } + + composeTestRule.onNodeWithText("Unclear", useUnmergedTree = true) + .assertIsDisplayed() + } + + private fun createAddNoteState( + highlightedText: String = "Test highlighted text", + userComment: String = "", + type: NotebookType = NotebookType.Important, + hasContentChange: Boolean = false, + isLoading: Boolean = false, + showDeleteConfirmationDialog: Boolean = false, + showExitConfirmationDialog: Boolean = false + ): AddEditNoteUiState { + return AddEditNoteUiState( + title = context.getString(R.string.createNoteTitle), + type = type, + highlightedData = NoteHighlightedData( + selectedText = highlightedText, + range = NoteHighlightedDataRange(0, 10, "", ""), + textPosition = NoteHighlightedDataTextPosition(0, 10) + ), + userComment = TextFieldValue(userComment), + onUserCommentChanged = {}, + onTypeChanged = {}, + onSaveNote = {}, + onSnackbarDismiss = {}, + hasContentChange = hasContentChange, + isLoading = isLoading, + showDeleteConfirmationDialog = showDeleteConfirmationDialog, + showExitConfirmationDialog = showExitConfirmationDialog + ) + } + + private fun createEditNoteState( + highlightedText: String = "Test highlighted text", + userComment: String = "Existing comment", + type: NotebookType = NotebookType.Important, + hasContentChange: Boolean = false, + isLoading: Boolean = false, + lastModifiedDate: String? = "Updated 2 hours ago", + showDeleteConfirmationDialog: Boolean = false, + showExitConfirmationDialog: Boolean = false + ): AddEditNoteUiState { + return AddEditNoteUiState( + title = context.getString(R.string.editNoteTitle), + type = type, + highlightedData = NoteHighlightedData( + selectedText = highlightedText, + range = NoteHighlightedDataRange(0, 10, "", ""), + textPosition = NoteHighlightedDataTextPosition(0, 10) + ), + userComment = TextFieldValue(userComment), + onUserCommentChanged = {}, + onTypeChanged = {}, + onSaveNote = {}, + onSnackbarDismiss = {}, + onDeleteNote = {}, + hasContentChange = hasContentChange, + isLoading = isLoading, + lastModifiedDate = lastModifiedDate, + showDeleteConfirmationDialog = showDeleteConfirmationDialog, + showExitConfirmationDialog = showExitConfirmationDialog + ) + } +} diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/notebook/NotebookScreenUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/notebook/NotebookScreenUiTest.kt new file mode 100644 index 0000000000..8f7a97a0e3 --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/notebook/NotebookScreenUiTest.kt @@ -0,0 +1,339 @@ +/* + * 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.ui.features.notebook + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteObjectType +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.horizon.R +import com.instructure.horizon.features.notebook.NotebookScreen +import com.instructure.horizon.features.notebook.NotebookUiState +import com.instructure.horizon.features.notebook.common.model.Note +import com.instructure.horizon.features.notebook.common.model.NotebookType +import com.instructure.horizon.horizonui.platform.LoadingState +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Date + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class NotebookScreenUiTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun testEmptyStateDisplaysWhenNoNotes() { + val state = createEmptyState() + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(context.getString(R.string.notesEmptyContentTitle)) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysHighlightedText() { + val highlightedText = "This is important highlighted text from the course material" + val state = createStateWithNotes( + notes = listOf(createTestNote(highlightedText = highlightedText)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(highlightedText, substring = true) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysUserComment() { + val userComment = "My personal note about this concept" + val state = createStateWithNotes( + notes = listOf(createTestNote(userText = userComment)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(userComment, substring = true) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysDate() { + val state = createStateWithNotes( + notes = listOf(createTestNote()) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNode(hasText("Jan", substring = true)) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysTypeImportant() { + val state = createStateWithNotes( + notes = listOf(createTestNote(type = NotebookType.Important)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText("Important", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testNoteCardDisplaysTypeConfusing() { + val state = createStateWithNotes( + notes = listOf(createTestNote(type = NotebookType.Confusing)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText("Unclear", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testCourseFilterDisplayedWhenEnabled() { + val state = createStateWithNotes( + showCourseFilter = true, + courses = listOf(createTestCourse()) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(context.getString(R.string.notebookFilterCoursePlaceholder), useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testNoteTypeFilterDisplayed() { + val state = createStateWithNotes( + showNoteTypeFilter = true, + notes = listOf(createTestNote()) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText("All notes", useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun testCourseNameDisplayedWhenCourseFilterVisible() { + val courseName = "Biology 101" + val state = createStateWithNotes( + showCourseFilter = true, + courses = listOf(createTestCourse(name = courseName, id = 123L)), + notes = listOf(createTestNote(courseId = 123L)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(courseName, substring = true) + .assertIsDisplayed() + } + + @Test + fun testCourseNameNotDisplayedWhenCourseFilterHidden() { + val courseName = "Biology 101" + val state = createStateWithNotes( + showCourseFilter = false, + courses = listOf(createTestCourse(name = courseName, id = 123L)), + notes = listOf(createTestNote(courseId = 123L)) + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNode(hasText(courseName)) + .assertDoesNotExist() + } + + @Test + fun testEmptyFilteredStateDisplayedWhenFilterApplied() { + val state = createEmptyState(selectedFilter = NotebookType.Important) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(context.getString(R.string.notesEmptyFilteredContentTitle)) + .assertIsDisplayed() + } + + @Test + fun testMultipleNotesDisplayed() { + val note1 = createTestNote( + id = "1", + highlightedText = "First important concept" + ) + val note2 = createTestNote( + id = "2", + highlightedText = "Second important concept" + ) + val state = createStateWithNotes(notes = listOf(note1, note2)) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText("First important concept", substring = true) + .assertIsDisplayed() + composeTestRule.onNodeWithText("Second important concept", substring = true) + .assertIsDisplayed() + } + + @Test + fun testShowMoreButtonDisplayedWhenHasNextPage() { + val state = createStateWithNotes( + notes = listOf(createTestNote()), + hasNextPage = true + ) + + composeTestRule.setContent { + ContextKeeper.appContext = context + val navController = rememberNavController() + NotebookScreen(navController, state) + } + + composeTestRule.onNodeWithText(context.getString(R.string.showMore)) + .assertIsDisplayed() + } + + private fun createEmptyState( + selectedFilter: NotebookType? = null + ): NotebookUiState { + return NotebookUiState( + loadingState = LoadingState(isLoading = false), + notes = emptyList(), + selectedFilter = selectedFilter, + showCourseFilter = true, + showNoteTypeFilter = true + ) + } + + private fun createStateWithNotes( + notes: List = emptyList(), + showCourseFilter: Boolean = false, + showNoteTypeFilter: Boolean = false, + courses: List = emptyList(), + hasNextPage: Boolean = false, + selectedFilter: NotebookType? = null + ): NotebookUiState { + return NotebookUiState( + loadingState = LoadingState(isLoading = false), + notes = notes, + courses = courses, + showCourseFilter = showCourseFilter, + showNoteTypeFilter = showNoteTypeFilter, + hasNextPage = hasNextPage, + selectedFilter = selectedFilter + ) + } + + private fun createTestNote( + id: String = "note1", + highlightedText: String = "Test highlighted text from course material", + userText: String = "My personal annotation", + type: NotebookType = NotebookType.Important, + courseId: Long = 123L + ): Note { + return Note( + id = id, + highlightedText = NoteHighlightedData( + selectedText = highlightedText, + range = NoteHighlightedDataRange(0, highlightedText.length, "", ""), + textPosition = NoteHighlightedDataTextPosition(0, highlightedText.length) + ), + type = type, + userText = userText, + updatedAt = Date(1706140800000L), + courseId = courseId, + objectType = NoteObjectType.Assignment, + objectId = "assignment123" + ) + } + + private fun createTestCourse( + name: String = "Test Course", + id: Long = 123L + ): CourseWithProgress { + return CourseWithProgress( + courseId = id, + courseName = name, + progress = 0.0 + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/account/navigation/AccountNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/account/navigation/AccountNavigation.kt index 06f5ee5838..1c6930943f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/account/navigation/AccountNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/account/navigation/AccountNavigation.kt @@ -16,11 +16,9 @@ */ package com.instructure.horizon.features.account.navigation -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavBackStackEntry +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable @@ -38,10 +36,9 @@ import com.instructure.horizon.features.account.profile.AccountProfileScreen import com.instructure.horizon.features.account.profile.AccountProfileViewModel import com.instructure.horizon.features.account.reportabug.ReportABugWebView import com.instructure.horizon.features.home.HomeNavigationRoute +import com.instructure.horizon.horizonui.animation.NavigationTransitionAnimation import com.instructure.horizon.horizonui.animation.enterTransition import com.instructure.horizon.horizonui.animation.exitTransition -import com.instructure.horizon.horizonui.animation.mainEnterTransition -import com.instructure.horizon.horizonui.animation.mainExitTransition import com.instructure.horizon.horizonui.animation.popEnterTransition import com.instructure.horizon.horizonui.animation.popExitTransition @@ -51,10 +48,10 @@ fun NavGraphBuilder.accountNavigation( navigation( route = HomeNavigationRoute.Account.route, startDestination = AccountRoute.Account.route, - enterTransition = { if (isBottomNavDestination()) mainEnterTransition else enterTransition }, - exitTransition = { if (isBottomNavDestination()) mainExitTransition else exitTransition }, - popEnterTransition = { if (isBottomNavDestination()) mainEnterTransition else popEnterTransition }, - popExitTransition = { if (isBottomNavDestination()) mainExitTransition else popExitTransition }, + enterTransition = { enterTransition(NavigationTransitionAnimation.SCALE) }, + exitTransition = { exitTransition(NavigationTransitionAnimation.SCALE) }, + popEnterTransition = { popEnterTransition(NavigationTransitionAnimation.SCALE) }, + popExitTransition = { popExitTransition(NavigationTransitionAnimation.SCALE) }, ) { composable( route = AccountRoute.Account.route, @@ -96,15 +93,4 @@ fun NavGraphBuilder.accountNavigation( ReportABugWebView(navController) } } -} - -private fun AnimatedContentTransitionScope.isBottomNavDestination(): Boolean { - val sourceRoute = this.initialState.destination.route ?: return false - val destinationRoute = this.targetState.destination.route ?: return false - return sourceRoute.contains(HomeNavigationRoute.Learn.route) - || sourceRoute.contains(HomeNavigationRoute.Dashboard.route) - || sourceRoute.contains(HomeNavigationRoute.Skillspace.route) - || destinationRoute.contains(HomeNavigationRoute.Learn.route) - || destinationRoute.contains(HomeNavigationRoute.Dashboard.route) - || destinationRoute.contains(HomeNavigationRoute.Skillspace.route) } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/navigation/AiAssistNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/navigation/AiAssistNavigation.kt index 83395fbef3..c857255806 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/navigation/AiAssistNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/aiassistant/navigation/AiAssistNavigation.kt @@ -22,7 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt index 57b515c1d0..b4f1809ca2 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt @@ -22,13 +22,19 @@ import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -65,6 +71,7 @@ import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.dashboard.widget.announcement.DashboardAnnouncementBannerWidget import com.instructure.horizon.features.dashboard.widget.course.DashboardCourseSection import com.instructure.horizon.features.dashboard.widget.myprogress.DashboardMyProgressWidget @@ -76,6 +83,7 @@ import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonElevation import com.instructure.horizon.horizonui.foundation.HorizonSpace import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.isWideLayout import com.instructure.horizon.horizonui.molecules.Badge import com.instructure.horizon.horizonui.molecules.BadgeContent import com.instructure.horizon.horizonui.molecules.BadgeType @@ -191,42 +199,7 @@ fun DashboardScreen(uiState: DashboardUiState, mainNavController: NavHostControl refreshStateFlow ) HorizonSpace(SpaceSize.SPACE_16) - val pagerState = rememberPagerState{ 3 } - AnimatedHorizontalPager( - pagerState, - sizeAnimationRange = 0f, - contentPadding = PaddingValues(horizontal = 24.dp), - pageSpacing = 12.dp, - verticalAlignment = Alignment.CenterVertically, - ) { index, modifier -> - when (index) { - 0 -> { - DashboardMyProgressWidget( - shouldRefresh, - refreshStateFlow, - modifier.padding(bottom = 16.dp) - ) - } - 1 -> { - DashboardTimeSpentWidget( - shouldRefresh, - refreshStateFlow, - modifier.padding(bottom = 16.dp) - ) - } - 2 -> { - DashboardSkillOverviewWidget( - homeNavController, - shouldRefresh, - refreshStateFlow, - modifier.padding(bottom = 16.dp) - ) - } - else -> { - - } - } - } + NumericWidgetRow(shouldRefresh, refreshStateFlow, homeNavController) DashboardSkillHighlightsWidget( homeNavController, shouldRefresh, @@ -303,6 +276,98 @@ private fun HomeScreenTopBar(uiState: DashboardUiState, mainNavController: NavCo } } +@Composable +private fun NumericWidgetRow( + shouldRefresh: Boolean, + refreshStateFlow: MutableStateFlow>, + homeNavController: NavHostController +) { + BoxWithConstraints { + val pageCount = 3 + val pagerState = rememberPagerState{ pageCount } + if (this.isWideLayout) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 12.dp) + ) { + DashboardMyProgressWidget( + shouldRefresh, + refreshStateFlow, + DashboardWidgetPageState.Empty, + Modifier.width(IntrinsicSize.Max) + ) + DashboardTimeSpentWidget( + shouldRefresh, + refreshStateFlow, + DashboardWidgetPageState.Empty, + Modifier.width(IntrinsicSize.Max) + ) + DashboardSkillOverviewWidget( + homeNavController, + shouldRefresh, + refreshStateFlow, + DashboardWidgetPageState.Empty, + Modifier.width(IntrinsicSize.Max) + ) + } + } else { + AnimatedHorizontalPager( + pagerState, + sizeAnimationRange = 0f, + beyondViewportPageCount = 3, + contentPadding = PaddingValues(horizontal = 24.dp), + pageSpacing = 12.dp, + verticalAlignment = Alignment.CenterVertically, + ) { index, modifier -> + when (index) { + 0 -> { + DashboardMyProgressWidget( + shouldRefresh, + refreshStateFlow, + DashboardWidgetPageState(index + 1, pageCount), + modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + } + + 1 -> { + DashboardTimeSpentWidget( + shouldRefresh, + refreshStateFlow, + DashboardWidgetPageState(index + 1, pageCount), + modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + } + + 2 -> { + DashboardSkillOverviewWidget( + homeNavController, + shouldRefresh, + refreshStateFlow, + DashboardWidgetPageState(index + 1, pageCount), + modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + } + + else -> { + + } + } + } + } + } +} + @Composable private fun NotificationPermissionRequest() { val context = LocalContext.current diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardPaginatedWidgetCard.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardPaginatedWidgetCard.kt index 51cc85b63c..49338dbec3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardPaginatedWidgetCard.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardPaginatedWidgetCard.kt @@ -18,8 +18,6 @@ package com.instructure.horizon.features.dashboard.widget import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.rememberPagerState @@ -29,24 +27,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R -import com.instructure.horizon.features.dashboard.DashboardCard import com.instructure.horizon.horizonui.animation.shimmerEffect import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonSpace import com.instructure.horizon.horizonui.foundation.HorizonTypography import com.instructure.horizon.horizonui.foundation.SpaceSize -import com.instructure.horizon.horizonui.molecules.StatusChip -import com.instructure.horizon.horizonui.molecules.StatusChipColor -import com.instructure.horizon.horizonui.molecules.StatusChipState import com.instructure.horizon.horizonui.organisms.AnimatedHorizontalPager import com.instructure.pandautils.utils.localisedFormat import java.util.Date @@ -71,13 +63,23 @@ fun DashboardPaginatedWidgetCard( pageSpacing = 12.dp, verticalAlignment = Alignment.CenterVertically, ) { index, modifier -> - DashboardCard( + val item = state.items[index] + DashboardWidgetCard( + title = item.headerState.label, + iconRes = item.headerState.iconRes, + useMinWidth = false, + isLoading = state.isLoading, + widgetColor = item.headerState.color, + pageState = DashboardWidgetPageState( + currentPageNumber = pagerState.currentPage + 1, + pageCount = state.items.size + ), modifier = modifier.padding(bottom = 16.dp), onClick = if (state.isLoading) { null } else { { - state.items[index].route?.let { route -> + item.route?.let { route -> when (route) { is DashboardPaginatedWidgetCardButtonRoute.HomeRoute -> { homeNavController.navigate(route.route) @@ -96,19 +98,10 @@ fun DashboardPaginatedWidgetCard( modifier = Modifier .fillMaxWidth() ) { - HorizonSpace(SpaceSize.SPACE_24) - DashboardPaginatedWidgetCardItem( - item = state.items[index], + item = item, isLoading = state.isLoading, - modifier = Modifier - .padding(horizontal = 24.dp) - .semantics(mergeDescendants = true) { - role = Role.Button - } ) - - HorizonSpace(SpaceSize.SPACE_24) } } } @@ -124,44 +117,14 @@ private fun DashboardPaginatedWidgetCardItem( Column( modifier = modifier.fillMaxWidth() ) { - if (item.chipState != null || item.pageState != null) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - item.chipState?.let { chipState -> - StatusChip( - state = StatusChipState( - label = chipState.label, - color = chipState.color, - fill = true - ), - modifier = Modifier.shimmerEffect( - isLoading, - backgroundColor = chipState.color.fillColor.copy(0.8f), - shimmerColor = chipState.color.fillColor.copy(0.5f) - ) - ) - } - - Spacer(Modifier.weight(1f)) - - item.pageState?.let { - Text( - it, - style = HorizonTypography.p2, - color = HorizonColors.Text.dataPoint(), - ) - } - } - HorizonSpace(SpaceSize.SPACE_16) - } item.source?.let { source -> Text( text = stringResource( R.string.dashboardAnnouncementBannerFrom, source ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, style = HorizonTypography.p2, color = HorizonColors.Text.dataPoint(), modifier = Modifier.shimmerEffect(isLoading) @@ -182,13 +145,13 @@ private fun DashboardPaginatedWidgetCardItem( Text( text = title, style = HorizonTypography.p1, + maxLines = 3, + overflow = TextOverflow.Ellipsis, color = HorizonColors.Text.body(), modifier = Modifier .fillMaxWidth() .shimmerEffect(isLoading) ) - - HorizonSpace(SpaceSize.SPACE_16) } } } @@ -201,9 +164,10 @@ private fun DashboardPaginatedWidgetCardAnnouncementContentPreview() { state = DashboardPaginatedWidgetCardState( items = listOf( DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.ic_announcement ), title = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Announcement title shown here.", source = "Institution or Course Name Here", @@ -211,9 +175,10 @@ private fun DashboardPaginatedWidgetCardAnnouncementContentPreview() { route = DashboardPaginatedWidgetCardButtonRoute.MainRoute("") ), DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.ic_announcement ), title = "Second announcement with different content to show pagination.", source = "Another Course Name", @@ -221,9 +186,10 @@ private fun DashboardPaginatedWidgetCardAnnouncementContentPreview() { route = DashboardPaginatedWidgetCardButtonRoute.MainRoute("") ), DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = "Announcement", - color = StatusChipColor.Sky + color = HorizonColors.Surface.institution().copy(alpha = 0.1f), + iconRes = R.drawable.ic_announcement ), title = "Third global announcement without a source.", date = Date(), @@ -241,21 +207,7 @@ private fun DashboardPaginatedWidgetCardAnnouncementContentPreview() { private fun DashboardPaginatedWidgetCardAnnouncementLoadingPreview() { ContextKeeper.appContext = LocalContext.current DashboardPaginatedWidgetCard( - state = DashboardPaginatedWidgetCardState( - items = listOf( - DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( - label = "Announcement", - color = StatusChipColor.Sky - ), - title = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Announcement title shown here.", - source = "Institution or Course Name Here", - date = Date(), - route = DashboardPaginatedWidgetCardButtonRoute.MainRoute("") - ), - ), - isLoading = true - ), + state = DashboardPaginatedWidgetCardState.Loading, rememberNavController(), rememberNavController(), ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardPaginatedWidgetCardState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardPaginatedWidgetCardState.kt index f843a694a0..b7c251e3a0 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardPaginatedWidgetCardState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardPaginatedWidgetCardState.kt @@ -1,25 +1,49 @@ package com.instructure.horizon.features.dashboard.widget -import com.instructure.horizon.horizonui.molecules.StatusChipColor +import androidx.annotation.DrawableRes +import androidx.compose.ui.graphics.Color +import com.instructure.horizon.R +import com.instructure.horizon.horizonui.foundation.HorizonColors import java.util.Date data class DashboardPaginatedWidgetCardState( val items: List = emptyList(), val isLoading: Boolean = false, -) +) { + companion object { + val Loading = DashboardPaginatedWidgetCardState( + items = listOf(DashboardPaginatedWidgetCardItemState.Loading), + isLoading = true + ) + } +} data class DashboardPaginatedWidgetCardItemState( - val chipState: DashboardPaginatedWidgetCardChipState? = null, - val pageState: String? = null, + val headerState: DashboardPaginatedWidgetCardHeaderState, val source: String? = null, val date: Date? = null, val title: String? = null, val route: DashboardPaginatedWidgetCardButtonRoute? = null -) +) { + companion object { + val Loading = DashboardPaginatedWidgetCardItemState( + headerState = DashboardPaginatedWidgetCardHeaderState( + label = "Announcement", + color = HorizonColors.PrimitivesSky.sky12, + iconRes = R.drawable.campaign + ), + title = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Announcement title shown here.", + source = "Institution or Course Name Here", + date = Date(), + route = DashboardPaginatedWidgetCardButtonRoute.MainRoute("") + ) + } +} -data class DashboardPaginatedWidgetCardChipState( +data class DashboardPaginatedWidgetCardHeaderState( val label: String, - val color: StatusChipColor, + val color: Color, + @DrawableRes val iconRes: Int, ) sealed class DashboardPaginatedWidgetCardButtonRoute { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCard.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCard.kt index 9895079e92..904bc04b28 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCard.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCard.kt @@ -18,12 +18,12 @@ package com.instructure.horizon.features.dashboard.widget import androidx.annotation.DrawableRes 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.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -38,9 +38,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.instructure.horizon.R @@ -52,12 +53,22 @@ import com.instructure.horizon.horizonui.foundation.HorizonTypography import com.instructure.horizon.horizonui.foundation.SpaceSize import com.instructure.pandautils.compose.modifiers.conditional +data class DashboardWidgetPageState( + val currentPageNumber: Int, + val pageCount: Int +) { + companion object { + val Empty = DashboardWidgetPageState(0, 0) + } +} + @Composable fun DashboardWidgetCard( title: String, @DrawableRes iconRes: Int, widgetColor: Color, modifier: Modifier = Modifier, + pageState: DashboardWidgetPageState? = null, isLoading: Boolean = false, useMinWidth: Boolean = true, onClick: (() -> Unit)? = null, @@ -66,7 +77,6 @@ fun DashboardWidgetCard( val context = LocalContext.current DashboardCard( modifier - .semantics(mergeDescendants = true) { } .conditional(isLoading) { clearAndSetSemantics { contentDescription = @@ -83,19 +93,12 @@ fun DashboardWidgetCard( } ) { Row( - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .width(IntrinsicSize.Max) + .padding(bottom = 16.dp) ) { - Text( - text = title, - style = HorizonTypography.labelMediumBold, - color = HorizonColors.Text.dataPoint(), - modifier = Modifier - .padding(end = 8.dp) - .shimmerEffect(isLoading) - ) - Box( contentAlignment = Alignment.Center, modifier = Modifier @@ -116,9 +119,32 @@ fun DashboardWidgetCard( .size(16.dp) ) } - } + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = HorizonTypography.labelMediumBold, + color = HorizonColors.Text.dataPoint(), + modifier = Modifier + .weight(1f) + .shimmerEffect(isLoading) + ) - HorizonSpace(SpaceSize.SPACE_8) + if (pageState != null && pageState.pageCount > 1) { + HorizonSpace(SpaceSize.SPACE_8) + Text( + stringResource( + R.string.dashboardPaginatedWidgetPagerMessage, + pageState.currentPageNumber, + pageState.pageCount + ), + style = HorizonTypography.p2, + color = HorizonColors.Text.dataPoint(), + modifier = Modifier.shimmerEffect(isLoading) + ) + } + } content() } @@ -139,4 +165,21 @@ private fun DashboardTimeSpentCardPreview() { color = HorizonColors.Text.body() ) } +} + +@Composable +@Preview +private fun DashboardTimeSpentCardPaginatedPreview() { + DashboardWidgetCard( + title = "Time", + pageState = DashboardWidgetPageState(1, 2), + iconRes = R.drawable.schedule, + widgetColor = HorizonColors.PrimitivesBlue.blue12() + ) { + Text( + text = "Content", + style = HorizonTypography.h1, + color = HorizonColors.Text.body() + ) + } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCardError.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCardError.kt index 2253a612c7..d24d88cf6b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCardError.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCardError.kt @@ -47,6 +47,7 @@ fun DashboardWidgetCardError( @DrawableRes iconRes: Int, widgetColor: Color, useMinWidth: Boolean, + pageState: DashboardWidgetPageState, onRetryClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -56,6 +57,7 @@ fun DashboardWidgetCardError( iconRes = iconRes, widgetColor = widgetColor, useMinWidth = useMinWidth, + pageState = pageState, modifier = modifier .semantics(mergeDescendants = true) { role = Role.Button diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerViewModel.kt index 5a66d060f1..24ac157271 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerViewModel.kt @@ -26,10 +26,10 @@ import com.instructure.horizon.features.dashboard.DashboardEvent import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardButtonRoute -import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardChipState +import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardHeaderState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardItemState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardState -import com.instructure.horizon.horizonui.molecules.StatusChipColor +import com.instructure.horizon.horizonui.foundation.HorizonColors import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -79,8 +79,8 @@ class DashboardAnnouncementBannerViewModel @Inject constructor( it.copy( state = DashboardItemState.SUCCESS, cardState = DashboardPaginatedWidgetCardState( - items = announcements.mapIndexed { index, announcement -> - announcement.toPaginatedWidgetCardItemState(context, index, announcements.size) + items = announcements.map { announcement -> + announcement.toPaginatedWidgetCardItemState(context) } ) ) @@ -100,21 +100,13 @@ class DashboardAnnouncementBannerViewModel @Inject constructor( } } -private fun AnnouncementBannerItem.toPaginatedWidgetCardItemState( - context: Context, - itemIndex: Int, - size: Int, -): DashboardPaginatedWidgetCardItemState { +private fun AnnouncementBannerItem.toPaginatedWidgetCardItemState(context: Context): DashboardPaginatedWidgetCardItemState { return DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = context.getString(R.string.notificationsAnnouncementCategoryLabel), - color = StatusChipColor.Sky + color = HorizonColors.PrimitivesSky.sky12, + iconRes = R.drawable.campaign ), - pageState = if (size > 1) { - context.getString(R.string.dsahboardPaginatedWidgetPagerMessage, itemIndex + 1, size) - } else { - null - }, source = source, date = date, title = title, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerWidget.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerWidget.kt index d949af71e9..3ab7eb06a1 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerWidget.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerWidget.kt @@ -16,27 +16,22 @@ */ package com.instructure.horizon.features.dashboard.widget.announcement -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCard -import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardButtonRoute -import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardChipState -import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardItemState -import com.instructure.horizon.features.dashboard.widget.announcement.card.DashboardAnnouncementBannerCardError -import com.instructure.horizon.horizonui.molecules.StatusChipColor +import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardState +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetCardError +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState +import com.instructure.horizon.horizonui.foundation.HorizonColors import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import java.util.Date @Composable fun DashboardAnnouncementBannerWidget( @@ -71,29 +66,19 @@ fun DashboardAnnouncementBannerSection( when (state.state) { DashboardItemState.LOADING -> { DashboardPaginatedWidgetCard( - state.cardState.copy( - items = listOf( - DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( - label = stringResource(R.string.notificationsAnnouncementCategoryLabel), - color = StatusChipColor.Sky - ), - title = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Announcement title shown here.", - source = "Institution or Course Name Here", - date = Date(), - route = DashboardPaginatedWidgetCardButtonRoute.MainRoute("") - ) - ), - isLoading = true - ), + DashboardPaginatedWidgetCardState.Loading, mainNavController, homeNavController, ) } DashboardItemState.ERROR -> { - DashboardAnnouncementBannerCardError( + DashboardWidgetCardError( + stringResource(R.string.notificationsAnnouncementCategoryLabel), + R.drawable.campaign, + HorizonColors.PrimitivesSky.sky12, + false, + DashboardWidgetPageState.Empty, { state.onRefresh {} }, - Modifier.padding(horizontal = 16.dp) ) } DashboardItemState.SUCCESS -> { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSection.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSection.kt index baafacd090..b415e77c14 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSection.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseSection.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Text @@ -27,11 +28,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import com.instructure.horizon.R @@ -41,16 +46,24 @@ import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidge import com.instructure.horizon.features.dashboard.widget.course.card.CardClickAction import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardContent import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardError -import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardLoading import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardState +import com.instructure.horizon.features.dashboard.widget.course.card.DashboardMoreCourseCard import com.instructure.horizon.features.home.HomeNavigationRoute import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius +import com.instructure.horizon.horizonui.foundation.HorizonElevation import com.instructure.horizon.horizonui.foundation.HorizonTypography +import com.instructure.horizon.horizonui.foundation.horizonShadow +import com.instructure.horizon.horizonui.molecules.Button +import com.instructure.horizon.horizonui.molecules.ButtonColor +import com.instructure.horizon.horizonui.molecules.ButtonIconPosition +import com.instructure.horizon.horizonui.molecules.ButtonWidth import com.instructure.horizon.horizonui.organisms.AnimatedHorizontalPager -import com.instructure.horizon.horizonui.organisms.AnimatedHorizontalPagerIndicator import com.instructure.horizon.navigation.MainNavigationRoute +import com.instructure.pandautils.utils.toDp import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlin.math.min @Composable fun DashboardCourseSection( @@ -82,7 +95,12 @@ fun DashboardCourseSection( ) { when(state.state) { DashboardItemState.LOADING -> { - DashboardCourseCardLoading(Modifier.padding(horizontal = 24.dp)) + DashboardCourseCardContent( + DashboardCourseCardState.Loading, + { handleClickAction(it, mainNavController, homeNavController) }, + true, + modifier = Modifier.padding(horizontal = 24.dp) + ) } DashboardItemState.ERROR -> { DashboardCourseCardError({state.onRefresh {} }, Modifier.padding(horizontal = 24.dp)) @@ -99,7 +117,8 @@ private fun DashboardCourseSectionContent( mainNavController: NavHostController, homeNavController: NavHostController ) { - val pagerState = rememberPagerState { state.courses.size } + // Display 4 cards at most + val pagerState = rememberPagerState { min(4, state.courses.size) } Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -113,6 +132,7 @@ private fun DashboardCourseSectionContent( } if (state.courses.isNotEmpty()) { + var maxCardHeight by remember { mutableStateOf(0) } AnimatedHorizontalPager( pagerState, beyondViewportPageCount = pagerState.pageCount, @@ -120,17 +140,45 @@ private fun DashboardCourseSectionContent( pageSpacing = 12.dp, verticalAlignment = Alignment.CenterVertically, ) { index, modifier -> - DashboardCourseItem( - state.courses[index], - mainNavController, - homeNavController, - modifier.padding(bottom = 8.dp) - ) - } + when (index) { + in 0..2 -> { + DashboardCourseItem( + state.courses[index], + mainNavController, + homeNavController, + modifier.padding(bottom = 12.dp) + .onGloballyPositioned { coordinates -> + val cardHeight = coordinates.size.height + if (cardHeight > maxCardHeight) { maxCardHeight = cardHeight } + } + ) + } + else -> { + DashboardMoreCourseCard( + state.courses.size, + modifier + .padding(bottom = 12.dp) + .height(maxCardHeight.toDp.dp) + ) { + homeNavController.navigate(HomeNavigationRoute.CourseList.route) + } + } + } - if (pagerState.pageCount >= 4) { - AnimatedHorizontalPagerIndicator(pagerState) } + + Button( + stringResource(R.string.dashboardSeeAllCoursesLabel), + onClick = { + homeNavController.navigate(HomeNavigationRoute.CourseList.route) + }, + width = ButtonWidth.FILL, + color = ButtonColor.WhiteWithOutline, + iconPosition = ButtonIconPosition.End(R.drawable.arrow_forward), + modifier = Modifier + .padding(horizontal = 24.dp) + .horizonShadow(HorizonElevation.level4, HorizonCornerRadius.level6) + ) } else { DashboardCard( Modifier.padding(horizontal = 24.dp) @@ -160,7 +208,9 @@ private fun DashboardCourseItem( modifier = modifier.fillMaxWidth() ){ DashboardCourseCardContent( - cardState, { handleClickAction(it, mainNavController, homeNavController) } + cardState, + { handleClickAction(it, mainNavController, homeNavController) }, + false ) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseViewModel.kt index bbcc31c2c3..5a839f4e05 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardCourseViewModel.kt @@ -25,10 +25,9 @@ import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.features.dashboard.DashboardEvent import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardState import com.instructure.horizon.features.dashboard.widget.course.card.CardClickAction import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardModuleItemState -import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardState -import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardState import com.instructure.horizon.model.LearningObjectType import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.pandautils.utils.formatIsoDuration @@ -110,22 +109,7 @@ class DashboardCourseViewModel @Inject constructor( nextModuleForCourse = { courseId -> fetchNextModuleState(courseId, forceNetwork) }, - ).map { state -> - if (state.buttonState?.onClickAction is CardClickAction.Action) { - state.copy(buttonState = state.buttonState.copy( - onClickAction = CardClickAction.Action { - viewModelScope.tryLaunch { - updateCourseButtonState(state, isLoading = true) - state.buttonState.action() - onRefresh() - updateCourseButtonState(state, isLoading = false) - } catch { - updateCourseButtonState(state, isLoading = false) - } - }, - )) - } else state - } + ) val programCardStates = programs .filter { program -> program.sortedRequirements.none { it.enrollmentStatus == ProgramProgressCourseEnrollmentStatus.ENROLLED } } @@ -156,22 +140,4 @@ class DashboardCourseViewModel @Inject constructor( onClickAction = CardClickAction.NavigateToModuleItem(courseId, nextModuleItem.id) ) } - - private fun updateCourseButtonState(state: DashboardCourseCardState, isLoading: Boolean) { - _uiState.update { - it.copy( - courses = it.courses.map { originalState -> - if (originalState.title == state.title && originalState.parentPrograms == state.parentPrograms) { - originalState.copy( - buttonState = originalState.buttonState?.copy( - isLoading = isLoading - ) - ) - } else { - originalState - } - } - ) - } - } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardMapper.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardMapper.kt index 2eb876877c..8a8d1f5583 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardMapper.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/DashboardMapper.kt @@ -22,41 +22,36 @@ import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.type.EnrollmentWorkflowState import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardButtonRoute -import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardChipState +import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardHeaderState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardItemState +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.dashboard.widget.course.card.CardClickAction +import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardDescriptionState import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardImageState import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardModuleItemState import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardParentProgramState import com.instructure.horizon.features.dashboard.widget.course.card.DashboardCourseCardState import com.instructure.horizon.features.home.HomeNavigationRoute -import com.instructure.horizon.horizonui.molecules.StatusChipColor +import com.instructure.horizon.horizonui.foundation.HorizonColors internal suspend fun List.mapToDashboardCourseCardState( context: Context, programs: List, nextModuleForCourse: suspend (Long?) -> DashboardCourseCardModuleItemState? ): List { - val completed = this.filter { it.isCompleted() }.map { it.mapCompleted(context, programs) } - val active = this.filter { it.isActive() }.map { it.mapActive(programs, nextModuleForCourse) } - return (active + completed).sortedByDescending { course -> - course.progress.run { if (this == 100.0) -1.0 else this } // Active courses first, then completed courses - ?: 0.0 - } + val completed = this.filter { it.isCompleted() }.mapCompleted(context, programs) + val active = this.filter { it.isActive() }.mapActive(programs, nextModuleForCourse) + return (active + completed).adjustAndSortCourseCardValues() } internal fun List.mapToDashboardCourseCardState(context: Context): List { - return this.mapIndexed { itemIndex, program -> + return this.map { program -> DashboardPaginatedWidgetCardItemState( - chipState = DashboardPaginatedWidgetCardChipState( + headerState = DashboardPaginatedWidgetCardHeaderState( label = context.getString(R.string.dashboardCourseCardProgramChipLabel), - color = StatusChipColor.Grey + color = HorizonColors.Surface.institution().copy(0.1f), + iconRes = R.drawable.book_2 ), - pageState = if (size > 1) { - context.getString(R.string.dsahboardPaginatedWidgetPagerMessage, itemIndex + 1, size) - } else { - null - }, title = context.getString( R.string.dashboardCourseCardProgramDetailsMessage, program.name @@ -78,54 +73,76 @@ private fun GetCoursesQuery.Enrollment.isMaxProgress(): Boolean { return this.course?.usersConnection?.nodes?.firstOrNull()?.courseProgression?.requirements?.completionPercentage == 100.0 } -private fun GetCoursesQuery.Enrollment.mapCompleted(context: Context, programs: List): DashboardCourseCardState { - return DashboardCourseCardState( - parentPrograms = programs - .filter { it.sortedRequirements.any { it.courseId == this.course?.id?.toLongOrNull() } } - .map { program -> - DashboardCourseCardParentProgramState( - programName = program.name, - programId = program.id, - onClickAction = CardClickAction.NavigateToProgram(program.id) - ) - }, - imageState = DashboardCourseCardImageState( - imageUrl = this.course?.image_download_url, - showPlaceholder = true - ), - title = this.course?.name.orEmpty(), - description = context.getString(R.string.dashboardCompletedCourseDetails), - progress = this.course?.usersConnection?.nodes?.firstOrNull()?.courseProgression?.requirements?.completionPercentage ?: 0.0, - moduleItem = null, - buttonState = null, - onClickAction = CardClickAction.NavigateToCourse(this.course?.id?.toLongOrNull() ?: -1L) - ) +private fun List.mapCompleted(context: Context, programs: List): List { + return map { item -> + DashboardCourseCardState( + parentPrograms = programs + .filter { it.sortedRequirements.any { it.courseId == item.course?.id?.toLongOrNull() } } + .map { program -> + DashboardCourseCardParentProgramState( + programName = program.name, + programId = program.id, + onClickAction = CardClickAction.NavigateToProgram(program.id) + ) + }, + imageState = DashboardCourseCardImageState( + imageUrl = item.course?.image_download_url, + showPlaceholder = true + ), + title = item.course?.name.orEmpty(), + descriptionState = DashboardCourseCardDescriptionState( + descriptionTitle = context.getString(R.string.dashboardCompletedCourseTitle), + description = context.getString(R.string.dashboardCompletedCourseMessage), + ), + progress = item.course?.usersConnection?.nodes?.firstOrNull()?.courseProgression?.requirements?.completionPercentage + ?: 0.0, + moduleItem = null, + onClickAction = CardClickAction.NavigateToCourse(item.course?.id?.toLongOrNull() ?: -1L) + ) + } } -private suspend fun GetCoursesQuery.Enrollment.mapActive( +private suspend fun List.mapActive( programs: List, nextModuleForCourse: suspend (Long?) -> DashboardCourseCardModuleItemState? -): DashboardCourseCardState { - return DashboardCourseCardState( - parentPrograms = programs - .filter { it.sortedRequirements.any { it.courseId == this.course?.id?.toLongOrNull() } } - .map { program -> - DashboardCourseCardParentProgramState( - programName = program.name, - programId = program.id, - onClickAction = CardClickAction.NavigateToProgram(program.id) - ) - }, - imageState = DashboardCourseCardImageState( - imageUrl = this.course?.image_download_url, - showPlaceholder = true - ), - title = this.course?.name.orEmpty(), - description = null, - progress = this.course?.usersConnection?.nodes?.firstOrNull()?.courseProgression?.requirements?.completionPercentage ?: 0.0, - moduleItem = nextModuleForCourse(this.course?.id?.toLongOrNull()), - buttonState = null, - onClickAction = CardClickAction.NavigateToCourse(this.course?.id?.toLongOrNull() ?: -1L), - lastAccessed = this.lastActivityAt - ) +): List { + return map { item -> + DashboardCourseCardState( + parentPrograms = programs + .filter { it.sortedRequirements.any { it.courseId == item.course?.id?.toLongOrNull() } } + .map { program -> + DashboardCourseCardParentProgramState( + programName = program.name, + programId = program.id, + onClickAction = CardClickAction.NavigateToProgram(program.id) + ) + }, + imageState = DashboardCourseCardImageState( + imageUrl = item.course?.image_download_url, + showPlaceholder = true + ), + title = item.course?.name.orEmpty(), + descriptionState = null, + progress = item.course?.usersConnection?.nodes?.firstOrNull()?.courseProgression?.requirements?.completionPercentage + ?: 0.0, + moduleItem = nextModuleForCourse(item.course?.id?.toLongOrNull()), + onClickAction = CardClickAction.NavigateToCourse( + item.course?.id?.toLongOrNull() ?: -1L + ), + ) + } +} + +private fun List.adjustAndSortCourseCardValues(): List { + return sortedByDescending { course -> + course.progress.run { if (this == 100.0) -1.0 else this } // Active courses first, then completed courses + ?: 0.0 + }.mapIndexed { index, item -> + item.copy( + pageState = DashboardWidgetPageState( + currentPageNumber = index + 1, + pageCount = size + ) + ) + } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardContent.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardContent.kt index 85cb418b6f..862e335cab 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardContent.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardContent.kt @@ -18,12 +18,13 @@ package com.instructure.horizon.features.dashboard.widget.course.card import android.graphics.drawable.Drawable import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -45,14 +47,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withLink import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi @@ -64,25 +59,18 @@ import com.bumptech.glide.request.target.Target import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardCard +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.horizonui.animation.shimmerEffect import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius import com.instructure.horizon.horizonui.foundation.HorizonSpace import com.instructure.horizon.horizonui.foundation.HorizonTypography import com.instructure.horizon.horizonui.foundation.SpaceSize -import com.instructure.horizon.horizonui.molecules.ButtonColor -import com.instructure.horizon.horizonui.molecules.ButtonHeight -import com.instructure.horizon.horizonui.molecules.ButtonIconPosition -import com.instructure.horizon.horizonui.molecules.ButtonWidth -import com.instructure.horizon.horizonui.molecules.LoadingButton -import com.instructure.horizon.horizonui.molecules.Pill -import com.instructure.horizon.horizonui.molecules.PillCase -import com.instructure.horizon.horizonui.molecules.PillSize -import com.instructure.horizon.horizonui.molecules.PillStyle -import com.instructure.horizon.horizonui.molecules.PillType +import com.instructure.horizon.horizonui.isWideLayout import com.instructure.horizon.horizonui.molecules.ProgressBarSmall import com.instructure.horizon.horizonui.molecules.ProgressBarStyle import com.instructure.horizon.horizonui.molecules.StatusChip +import com.instructure.horizon.horizonui.molecules.StatusChipColor import com.instructure.horizon.horizonui.molecules.StatusChipState import com.instructure.horizon.model.LearningObjectType import com.instructure.pandautils.utils.localisedFormatMonthDay @@ -93,6 +81,7 @@ import kotlin.math.roundToInt fun DashboardCourseCardContent( state: DashboardCourseCardState, handleOnClickAction: (CardClickAction?) -> Unit, + isLoading: Boolean, modifier: Modifier = Modifier ) { DashboardCard(modifier) { @@ -103,51 +92,135 @@ fun DashboardCourseCardContent( handleOnClickAction(state.onClickAction) } ) { - if (state.imageState != null) { - CourseImage(state.imageState) + BoxWithConstraints { + if (this.isWideLayout) { + DashboardCourseCardWideContent(state, isLoading, handleOnClickAction) + } else { + DashboardCourseCardCompactContent(state, isLoading, handleOnClickAction) + } + } + } + } +} + +@Composable +private fun DashboardCourseCardCompactContent( + state: DashboardCourseCardState, + isLoading: Boolean, + handleOnClickAction: (CardClickAction?) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth() + ){ + ImageWithProgramChips(state, isLoading, Modifier.fillMaxWidth()) + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + ) { + if (!state.title.isNullOrEmpty()) { + HorizonSpace(SpaceSize.SPACE_16) + TitleText(state.title, isLoading) + } + if (state.progress != null) { + HorizonSpace(SpaceSize.SPACE_12) + CourseProgress(state.progress, isLoading) } + if (state.descriptionState != null) { + HorizonSpace(SpaceSize.SPACE_16) + DescriptionText(state.descriptionState, isLoading) + } + if (state.moduleItem != null) { + HorizonSpace(SpaceSize.SPACE_16) + ModuleItemCard(state.moduleItem, isLoading, handleOnClickAction) + } + if (state.pageState != DashboardWidgetPageState.Empty) { + HorizonSpace(SpaceSize.SPACE_16) + PageIndicator(state.pageState, isLoading) + } + } + HorizonSpace(SpaceSize.SPACE_24) + } +} + +@Composable +private fun DashboardCourseCardWideContent( + state: DashboardCourseCardState, + isLoading: Boolean, + handleOnClickAction: (CardClickAction?) -> Unit, +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ImageWithProgramChips( + state, + isLoading, + Modifier + .width(320.dp) + .padding(start = 24.dp, top = 24.dp, end = 16.dp) + .clip(HorizonCornerRadius.level2) + ) Column( modifier = Modifier - .padding(horizontal = 24.dp) + .padding(end = 24.dp, top = 24.dp) ) { - if (state.chipState != null) { - Spacer(Modifier.height(24.dp)) - CardChip(state.chipState) - } - if (!state.parentPrograms.isNullOrEmpty()) { - Spacer(Modifier.height(16.dp)) - ProgramsText(state.parentPrograms, handleOnClickAction) - } if (!state.title.isNullOrEmpty()) { - Spacer(Modifier.height(16.dp)) - TitleText(state.title) + TitleText(state.title, isLoading) } if (state.progress != null) { - Spacer(Modifier.height(12.dp)) - CourseProgress(state.progress) + HorizonSpace(SpaceSize.SPACE_12) + CourseProgress(state.progress, isLoading) } - if (!state.description.isNullOrEmpty()) { - Spacer(Modifier.height(16.dp)) - DescriptionText(state.description) + if (state.descriptionState != null) { + HorizonSpace(SpaceSize.SPACE_16) + DescriptionText(state.descriptionState, isLoading) } if (state.moduleItem != null) { - Spacer(Modifier.height(16.dp)) - ModuleItemCard(state.moduleItem, handleOnClickAction) - } - if (state.buttonState != null) { - Spacer(Modifier.height(16.dp)) - DashboardCardButton(state.buttonState, handleOnClickAction) + HorizonSpace(SpaceSize.SPACE_16) + ModuleItemCard(state.moduleItem, isLoading, handleOnClickAction) } } - Spacer(Modifier.height(24.dp)) + } + if (state.pageState != DashboardWidgetPageState.Empty) { + HorizonSpace(SpaceSize.SPACE_16) + PageIndicator(state.pageState, isLoading) + } + HorizonSpace(SpaceSize.SPACE_24) + } +} + +@Composable +private fun ImageWithProgramChips( + state: DashboardCourseCardState, + isLoading: Boolean, + modifier: Modifier = Modifier +) { + Box(modifier) { + CourseImage(state.imageState, isLoading) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .padding(24.dp) + ) { + // Display only 3 programs at most + state.parentPrograms?.take(3)?.forEach { program -> + ProgramChip(program, isLoading) + } } } } @OptIn(ExperimentalGlideComposeApi::class) @Composable -private fun CourseImage(state: DashboardCourseCardImageState) { - var isLoading by rememberSaveable { mutableStateOf(true) } +private fun CourseImage( + state: DashboardCourseCardImageState, + isLoading: Boolean, + modifier: Modifier = Modifier +) { + var isImageLoading by rememberSaveable { mutableStateOf(true) } if (!state.imageUrl.isNullOrEmpty()) { GlideImage( state.imageUrl, @@ -161,7 +234,7 @@ private fun CourseImage(state: DashboardCourseCardImageState) { target: Target, isFirstResource: Boolean ): Boolean { - isLoading = false + isImageLoading = false return false } @@ -172,31 +245,30 @@ private fun CourseImage(state: DashboardCourseCardImageState) { dataSource: DataSource, isFirstResource: Boolean ): Boolean { - isLoading = false + isImageLoading = false return false } }) }, - modifier = Modifier - .fillMaxWidth() + modifier = modifier .aspectRatio(1.69f) - .shimmerEffect(isLoading) + .shimmerEffect(isLoading || isImageLoading) ) } else { if (state.showPlaceholder) { Box( contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() + modifier = modifier .aspectRatio(1.69f) .background(HorizonColors.Surface.institution().copy(alpha = 0.1f)) + .shimmerEffect(isLoading) ) { Icon( painterResource(R.drawable.book_2_filled), contentDescription = null, tint = HorizonColors.Surface.institution(), - modifier = Modifier.size(20.dp) + modifier = Modifier.size(32.dp) ) } } @@ -204,80 +276,54 @@ private fun CourseImage(state: DashboardCourseCardImageState) { } @Composable -private fun CardChip(state: DashboardCourseCardChipState) { - StatusChip( - StatusChipState( - label = state.label, - color = state.color, - fill = true, - iconRes = null - ) - ) -} - -@Composable -private fun ProgramsText( - programs: List, - handleOnClickAction: (CardClickAction?) -> Unit, +private fun TitleText( + title: String, + isLoading: Boolean, ) { - val programsAnnotated = buildAnnotatedString { - programs.forEachIndexed { i, program -> - if (i > 0) append(", ") - withLink( - LinkAnnotation.Clickable( - tag = program.programId, - styles = TextLinkStyles( - style = SpanStyle(textDecoration = TextDecoration.Underline) - ), - linkInteractionListener = { _ -> handleOnClickAction(program.onClickAction) } - ) - ) { - append(program.programName) - } - } - } - - // String resource can't work with annotated string so we need a temporary placeholder - val template = stringResource(R.string.learnScreen_partOfProgram, "__PROGRAMS__") - - val fullText = buildAnnotatedString { - val parts = template.split("__PROGRAMS__") - append(parts[0]) - append(programsAnnotated) - if (parts.size > 1) append(parts[1]) - } - - Text( - modifier = Modifier.semantics(mergeDescendants = true) {}, - style = HorizonTypography.p1, - text = fullText - ) -} - -@Composable -private fun TitleText(title: String) { Text( text = title, - style = HorizonTypography.h4, + style = HorizonTypography.labelLargeBold, color = HorizonColors.Text.title(), - maxLines = 2, - overflow = TextOverflow.Ellipsis + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.shimmerEffect(isLoading) ) } @Composable -private fun DescriptionText(description: String) { - Text( - text = description, - style = HorizonTypography.p1, - color = HorizonColors.Text.body(), - ) +private fun DescriptionText( + descriptionState: DashboardCourseCardDescriptionState, + isLoading: Boolean, +) { + Column { + Text( + text = descriptionState.descriptionTitle, + style = HorizonTypography.labelLargeBold, + color = HorizonColors.Text.title(), + modifier = Modifier.shimmerEffect(isLoading) + ) + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = descriptionState.description, + style = HorizonTypography.p1, + color = HorizonColors.Text.body(), + modifier = Modifier.shimmerEffect(isLoading) + ) + } } @Composable -private fun CourseProgress(progress: Double) { +private fun CourseProgress( + progress: Double, + isLoading: Boolean, +) { Row( verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.shimmerEffect( + isLoading, + backgroundColor = HorizonColors.Surface.institution().copy(0.1f), + shimmerColor = HorizonColors.Surface.institution().copy(0.05f), + ) ) { ProgressBarSmall( progress = progress, @@ -296,10 +342,34 @@ private fun CourseProgress(progress: Double) { } } +@Composable +private fun PageIndicator( + pageState: DashboardWidgetPageState, + isLoading: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ){ + Text( + stringResource( + R.string.dashboardPaginatedWidgetPagerMessage, + pageState.currentPageNumber, + pageState.pageCount + ), + style = HorizonTypography.p2, + color = HorizonColors.Text.dataPoint(), + modifier = Modifier.shimmerEffect(isLoading), + ) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun ModuleItemCard( state: DashboardCourseCardModuleItemState, + isLoading: Boolean, handleOnClickAction: (CardClickAction?) -> Unit, ) { Box( @@ -313,74 +383,106 @@ private fun ModuleItemCard( ) .clip(HorizonCornerRadius.level2) .clickable { handleOnClickAction(state.onClickAction) } + .shimmerEffect( + isLoading, + backgroundColor = HorizonColors.Surface.institution().copy(0.1f), + shimmerColor = HorizonColors.Surface.institution().copy(0.05f), + ) .padding(16.dp) ) { Column { Text( text = state.moduleItemTitle, - style = HorizonTypography.p1, + style = HorizonTypography.p2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, color = HorizonColors.Text.body() ) Spacer(Modifier.height(12.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), + Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Pill( - label = stringResource(state.moduleItemType.stringRes), - size = PillSize.SMALL, - style = PillStyle.SOLID, - type = PillType.INVERSE, - case = PillCase.TITLE, - iconRes = state.moduleItemType.iconRes, - ) - - if (state.dueDate != null) { - Pill( - label = stringResource(R.string.learningobject_dueDate, state.dueDate.localisedFormatMonthDay()), - size = PillSize.SMALL, - style = PillStyle.SOLID, - type = PillType.INVERSE, - case = PillCase.TITLE, - iconRes = R.drawable.calendar_today, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (state.estimatedDuration != null) { + StatusChip( + StatusChipState( + label = state.estimatedDuration, + color = StatusChipColor.White, + fill = true, + iconRes = R.drawable.schedule, + ) + ) + } + if (state.dueDate != null) { + StatusChip( + StatusChipState( + label = stringResource(R.string.learningobject_dueDate, state.dueDate.localisedFormatMonthDay()), + color = StatusChipColor.White, + fill = true, + iconRes = R.drawable.calendar_today, + ) + ) + } else { + StatusChip( + StatusChipState( + label = stringResource(R.string.dashboardCourseCardModuleItemNoDueDateLabel), + color = StatusChipColor.White, + fill = true, + iconRes = R.drawable.calendar_today, + ) + ) + } } - - if (state.estimatedDuration != null) { - Pill( - label = state.estimatedDuration, - size = PillSize.SMALL, - style = PillStyle.SOLID, - type = PillType.INVERSE, - case = PillCase.TITLE, - iconRes = R.drawable.calendar_today, + StatusChip( + StatusChipState( + label = stringResource(state.moduleItemType.stringRes), + color = StatusChipColor.White, + fill = true, + iconRes = state.moduleItemType.iconRes, ) - } + ) } } } } @Composable -private fun DashboardCardButton( - state: DashboardCourseCardButtonState, - handleOnClickAction: (CardClickAction?) -> Unit +private fun ProgramChip( + program: DashboardCourseCardParentProgramState, + isLoading: Boolean ) { - LoadingButton( - label = state.label, - height = ButtonHeight.SMALL, - width = ButtonWidth.RELATIVE, - color = ButtonColor.Black, - iconPosition = ButtonIconPosition.NoIcon, - onClick = { handleOnClickAction(state.onClickAction) }, - contentAlignment = Alignment.Center, - loading = state.isLoading, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background( + color = HorizonColors.Surface.pageSecondary(), + shape = HorizonCornerRadius.level1 + ) + .border(1.dp, HorizonColors.LineAndBorder.lineStroke(), HorizonCornerRadius.level1) + .shimmerEffect(isLoading) + .padding(horizontal = 12.dp, vertical = 2.dp) + ) { + Text( + stringResource(R.string.dashboardCourseCardProgramPrefix), + style = HorizonTypography.labelMediumBold, + color = HorizonColors.Text.title() + ) + Text(program.programName, + style = HorizonTypography.p2, + color = HorizonColors.Text.title(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } @Composable @Preview -private fun DashboardCourseCardWithModulePreview() { +private fun DashboardCourseCardWithModuleCompactPreview() { ContextKeeper.appContext = LocalContext.current val state = DashboardCourseCardState( @@ -389,24 +491,63 @@ private fun DashboardCourseCardWithModulePreview() { programName = "Program Name", programId = "1", onClickAction = CardClickAction.Action({}) + ), + DashboardCourseCardParentProgramState( + programName = "Program Name to test the overflow behaviour in the chip", + programId = "2", + onClickAction = CardClickAction.Action({}) ) ), - imageState = null, + imageState = DashboardCourseCardImageState( + imageUrl = null, + showPlaceholder = true + ), title = "Course Title That Might Be Really Long and Go On Two Lines", - description = "This is a description of the course. It might be really long and go on multiple lines.", progress = 45.0, moduleItem = DashboardCourseCardModuleItemState( moduleItemTitle = "Module Item Title That Might Be Really Long and Go On Two Lines", moduleItemType = LearningObjectType.ASSIGNMENT, dueDate = Date(), - estimatedDuration = "5 mins", + estimatedDuration = "32 Hours 25 Mins", onClickAction = CardClickAction.Action({}) ), - buttonState = DashboardCourseCardButtonState( - label = "Go to Course", + onClickAction = CardClickAction.Action({}) + ) + DashboardCourseCardContent(state, {}, false) +} + +@Composable +@Preview(widthDp = 720) +private fun DashboardCourseCardWithModuleWidePreview() { + ContextKeeper.appContext = LocalContext.current + + val state = DashboardCourseCardState( + parentPrograms = listOf( + DashboardCourseCardParentProgramState( + programName = "Program Name", + programId = "1", + onClickAction = CardClickAction.Action({}) + ), + DashboardCourseCardParentProgramState( + programName = "Program Name to test the overflow behaviour in the chip", + programId = "2", + onClickAction = CardClickAction.Action({}) + ) + ), + imageState = DashboardCourseCardImageState( + imageUrl = null, + showPlaceholder = true + ), + title = "Course Title That Might Be Really Long and Go On Two Lines", + progress = 45.0, + moduleItem = DashboardCourseCardModuleItemState( + moduleItemTitle = "Module Item Title That Might Be Really Long and Go On Two Lines", + moduleItemType = LearningObjectType.ASSIGNMENT, + dueDate = Date(), + estimatedDuration = "32 Hours 25 Mins", onClickAction = CardClickAction.Action({}) ), onClickAction = CardClickAction.Action({}) ) - DashboardCourseCardContent(state, {}) + DashboardCourseCardContent(state, {}, false) } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardLoading.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardLoading.kt deleted file mode 100644 index f13dd1fa93..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardLoading.kt +++ /dev/null @@ -1,129 +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.dashboard.widget.course.card - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.instructure.horizon.R -import com.instructure.horizon.features.dashboard.DashboardCard -import com.instructure.horizon.horizonui.animation.shimmerEffect -import com.instructure.horizon.horizonui.foundation.HorizonColors -import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius -import com.instructure.horizon.horizonui.foundation.HorizonSpace -import com.instructure.horizon.horizonui.foundation.SpaceSize - -@Composable -fun DashboardCourseCardLoading( - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - DashboardCard( - modifier - .clearAndSetSemantics { - contentDescription = context.getString( - R.string.a11y_dashboardWidgetLoadingContentDescription, - context.getString(R.string.a11y_dashboardCoursesSectionTitle) - ) - }, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding() - ) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1.69f) - .shimmerEffect( - true, - shape = HorizonCornerRadius.level0, - ) - ) - Column( - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) { - HorizonSpace(SpaceSize.SPACE_8) - - Box( - Modifier - .fillMaxWidth() - .height(50.dp) - .shimmerEffect(true) - ) - - HorizonSpace(SpaceSize.SPACE_8) - - Box( - Modifier - .fillMaxWidth() - .height(25.dp) - .shimmerEffect( - true, - ) - ) - - HorizonSpace(SpaceSize.SPACE_8) - - Box( - Modifier - .fillMaxWidth() - .height(25.dp) - .shimmerEffect( - true, - shape = HorizonCornerRadius.level6, - backgroundColor = HorizonColors.Surface.institution().copy(alpha = 0.1f) - ) - ) - - HorizonSpace(SpaceSize.SPACE_8) - - Box( - Modifier - .fillMaxWidth() - .height(100.dp) - .shimmerEffect( - true, - shape = HorizonCornerRadius.level2, - backgroundColor = HorizonColors.Surface.institution().copy(alpha = 0.1f) - ) - ) - - HorizonSpace(SpaceSize.SPACE_24) - } - } - } -} - -@Composable -@Preview -private fun DashboardCourseCardLoadingPreview() { - DashboardCourseCardLoading() -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardState.kt index 75e83a51f4..c4eb04bda3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardCourseCardState.kt @@ -1,21 +1,34 @@ package com.instructure.horizon.features.dashboard.widget.course.card -import com.instructure.horizon.horizonui.molecules.StatusChipColor +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.model.LearningObjectType import java.util.Date data class DashboardCourseCardState( - val chipState: DashboardCourseCardChipState? = null, val parentPrograms: List? = null, - val imageState: DashboardCourseCardImageState? = null, + val imageState: DashboardCourseCardImageState = DashboardCourseCardImageState(), val title: String? = null, - val description: String? = null, + val descriptionTitle: String? = null, + val descriptionState: DashboardCourseCardDescriptionState? = null, val progress: Double? = null, val moduleItem: DashboardCourseCardModuleItemState? = null, - val buttonState: DashboardCourseCardButtonState? = null, val onClickAction: CardClickAction? = null, - val lastAccessed: Date? = null, -) + val pageState: DashboardWidgetPageState = DashboardWidgetPageState.Empty, +) { + companion object { + val Loading = DashboardCourseCardState( + parentPrograms = listOf(DashboardCourseCardParentProgramState("Loading Program", "1", CardClickAction.Action {})), + imageState = DashboardCourseCardImageState(imageUrl = "url"), + title = "Loading Course Title", + progress = 20.0, + moduleItem = DashboardCourseCardModuleItemState( + moduleItemTitle = "Loading Module Item", + moduleItemType = LearningObjectType.PAGE, + onClickAction = CardClickAction.Action({}) + ) + ) + } +} data class DashboardCourseCardParentProgramState( val programName: String, @@ -31,21 +44,14 @@ data class DashboardCourseCardModuleItemState( val onClickAction: CardClickAction, ) -data class DashboardCourseCardButtonState( - val label: String, - val onClickAction: CardClickAction, - val isLoading: Boolean = false, - val action: suspend () -> Unit = { }, -) - -data class DashboardCourseCardChipState( - val label: String, - val color: StatusChipColor, -) - data class DashboardCourseCardImageState( val imageUrl: String? = null, - val showPlaceholder: Boolean = false, + val showPlaceholder: Boolean = true, +) + +data class DashboardCourseCardDescriptionState( + val descriptionTitle: String, + val description: String, ) sealed class CardClickAction { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/card/DashboardAnnouncementBannerCardError.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardMoreCourseCard.kt similarity index 53% rename from libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/card/DashboardAnnouncementBannerCardError.kt rename to libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardMoreCourseCard.kt index 0e0db7701b..0b328ed898 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/announcement/card/DashboardAnnouncementBannerCardError.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/card/DashboardMoreCourseCard.kt @@ -14,19 +14,25 @@ * along with this program. If not, see . * */ -package com.instructure.horizon.features.dashboard.widget.announcement.card +package com.instructure.horizon.features.dashboard.widget.course.card import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardCard import com.instructure.horizon.horizonui.foundation.HorizonColors @@ -35,53 +41,57 @@ import com.instructure.horizon.horizonui.foundation.HorizonTypography import com.instructure.horizon.horizonui.foundation.SpaceSize import com.instructure.horizon.horizonui.molecules.Button import com.instructure.horizon.horizonui.molecules.ButtonColor -import com.instructure.horizon.horizonui.molecules.ButtonHeight import com.instructure.horizon.horizonui.molecules.ButtonIconPosition -import com.instructure.horizon.horizonui.molecules.StatusChip -import com.instructure.horizon.horizonui.molecules.StatusChipColor -import com.instructure.horizon.horizonui.molecules.StatusChipState @Composable -fun DashboardAnnouncementBannerCardError( - onRetry: () -> Unit, - modifier: Modifier = Modifier +fun DashboardMoreCourseCard( + courseCount: Int, + modifier: Modifier = Modifier, + onMoreClicked: () -> Unit ) { - DashboardCard( - modifier = modifier - ) { + DashboardCard(modifier) { Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() + .fillMaxHeight() .padding(24.dp) + .verticalScroll(rememberScrollState()), ) { - StatusChip( - state = StatusChipState( - label = stringResource(R.string.notificationsAnnouncementCategoryLabel), - color = StatusChipColor.Sky, - fill = true - ), + Spacer(Modifier.weight(1f)) + + Icon( + painter = painterResource(R.drawable.book_2_filled), + contentDescription = null, + tint = HorizonColors.Surface.institution(), + modifier = Modifier.size(32.dp) ) + HorizonSpace(SpaceSize.SPACE_16) + + // No need to use plurals as this card is only displayed when there are 4+ courses. Text( - text = stringResource(R.string.dashboardAnnouncementBannerErrorMessage), - style = HorizonTypography.p2, - color = HorizonColors.Text.timestamp() + stringResource(R.string.dashboardCourseCardEnrolledCoursesMessage, courseCount), + style = HorizonTypography.p1, + color = HorizonColors.Text.title() ) - HorizonSpace(SpaceSize.SPACE_8) + + HorizonSpace(SpaceSize.SPACE_16) + Button( - label = stringResource(R.string.dashboardAnnouncementRefreshMessage), - iconPosition = ButtonIconPosition.End(R.drawable.restart_alt), - height = ButtonHeight.SMALL, - onClick = onRetry, - color = ButtonColor.BlackOutline + label = stringResource(R.string.dashboardCourseCardEnrolledCoursesSeeAllLabel), + onClick = onMoreClicked, + color = ButtonColor.WhiteWithOutline, + iconPosition = ButtonIconPosition.End(R.drawable.arrow_forward) ) + + Spacer(Modifier.weight(1f)) } } } @Composable @Preview -private fun DashboardAnnouncementBannerCardErrorPreview() { - ContextKeeper.appContext = LocalContext.current - DashboardAnnouncementBannerCardError(onRetry = {}) -} +private fun DashboardMoreCourseCardPreview() { + DashboardMoreCourseCard(10) {} +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListRepository.kt new file mode 100644 index 0000000000..140e986344 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListRepository.kt @@ -0,0 +1,38 @@ +/* + * 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.dashboard.widget.course.list + +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.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.utils.ApiPrefs +import javax.inject.Inject + +class DashboardCourseListRepository @Inject constructor( + private val apiPrefs: ApiPrefs, + private val getCoursesManager: HorizonGetCoursesManager, + private val getProgramsManager: GetProgramsManager +) { + suspend fun getCourses(foreRefresh: Boolean): List { + return getCoursesManager.getCoursesWithProgress(apiPrefs.user?.id ?: -1, foreRefresh).dataOrThrow + } + + suspend fun getPrograms(foreRefresh: Boolean): List { + return getProgramsManager.getPrograms(foreRefresh) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListScreen.kt new file mode 100644 index 0000000000..60cac57136 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListScreen.kt @@ -0,0 +1,308 @@ +/* + * 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.dashboard.widget.course.list + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.instructure.horizon.R +import com.instructure.horizon.features.dashboard.DashboardCard +import com.instructure.horizon.features.home.HomeNavigationRoute +import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonElevation +import com.instructure.horizon.horizonui.foundation.HorizonSpace +import com.instructure.horizon.horizonui.foundation.HorizonTypography +import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.molecules.Button +import com.instructure.horizon.horizonui.molecules.ButtonColor +import com.instructure.horizon.horizonui.molecules.ButtonWidth +import com.instructure.horizon.horizonui.molecules.IconButton +import com.instructure.horizon.horizonui.molecules.IconButtonColor +import com.instructure.horizon.horizonui.molecules.IconButtonSize +import com.instructure.horizon.horizonui.molecules.ProgressBarSmall +import com.instructure.horizon.horizonui.molecules.ProgressBarStyle +import com.instructure.horizon.horizonui.organisms.CollapsableScaffold +import com.instructure.horizon.horizonui.organisms.inputs.singleselect.SingleSelect +import com.instructure.horizon.horizonui.organisms.inputs.singleselect.SingleSelectInputSize +import com.instructure.horizon.horizonui.organisms.inputs.singleselect.SingleSelectState +import com.instructure.horizon.horizonui.platform.LoadingStateWrapper +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardCourseListScreen( + state: DashboardCourseListUiState, + homeNavController: NavHostController, +) { + LoadingStateWrapper(state.loadingState) { + CollapsableScaffold( + containerColor = HorizonColors.Surface.pagePrimary(), + topBar = { DashboardCourseListTopBar(homeNavController) }, + ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp), + ) { + stickyHeader { + DashboardCourseListHeader(state) + } + if (state.courses.isEmpty()) { + item { + EmptyCoursesMessage() + } + } else { + val visibleCourses = state.courses.take(state.visibleCourseCount) + items(visibleCourses) { + CourseItemCard(it, homeNavController) + } + + if (state.courses.size > state.visibleCourseCount) { + item { + Button( + label = stringResource(R.string.dashboardCourseListShowMore), + width = ButtonWidth.FILL, + color = ButtonColor.WhiteWithOutline, + onClick = state.onShowMoreCourses, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + } + } + } + } +} + +@Composable +private fun DashboardCourseListHeader(state: DashboardCourseListUiState) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(HorizonColors.Surface.pagePrimary()) + .padding(16.dp) + ) { + val context = LocalContext.current + var isMenuOpen by remember { mutableStateOf(false) } + + SingleSelect( + SingleSelectState( + isMenuOpen = isMenuOpen, + onMenuOpenChanged = { isMenuOpen = it }, + options = state.filterOptions.map { stringResource(it.labelRes) }, + selectedOption = stringResource(state.selectedFilterOption.labelRes), + onOptionSelected = { + state.onFilterOptionSelected(DashboardCourseListFilterOption.fromLabel(context, it)) + }, + size = SingleSelectInputSize.Small, + isFullWidth = false + ), + Modifier.width(IntrinsicSize.Max) + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = state.courses.size.toString(), + style = HorizonTypography.p1, + color = HorizonColors.Text.dataPoint(), + ) + } +} + +@Composable +private fun CourseItemCard( + courseState: DashboardCourseListCourseState, + homeNavController: NavHostController +) { + DashboardCard( + modifier = Modifier.padding(horizontal = 16.dp), + onClick = { + homeNavController.navigate(HomeNavigationRoute.Learn.withCourse(courseState.courseId)) + } + ) { + Column( + modifier = Modifier.padding(24.dp) + ) { + if (courseState.parentPrograms.isNotEmpty()) { + ProgramsText(courseState.parentPrograms) { + homeNavController.navigate(HomeNavigationRoute.Learn.withProgram(it)) + } + HorizonSpace(SpaceSize.SPACE_16) + } + + Text( + courseState.name, + style = HorizonTypography.labelLargeBold, + color = HorizonColors.Text.title(), + ) + + HorizonSpace(SpaceSize.SPACE_12) + + CourseProgress(courseState.progress) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DashboardCourseListTopBar(homeNavController: NavHostController) { + CenterAlignedTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = HorizonColors.Surface.pagePrimary(), + titleContentColor = HorizonColors.Text.title(), + navigationIconContentColor = HorizonColors.Icon.default() + ), + title = { + Text( + stringResource(R.string.dashboardCourseListTitle), + style = HorizonTypography.h3, + color = HorizonColors.Text.title() + ) + }, + navigationIcon = { + IconButton( + iconRes = R.drawable.arrow_back, + contentDescription = stringResource(R.string.a11yNavigateBack), + color = IconButtonColor.Inverse, + size = IconButtonSize.SMALL, + elevation = HorizonElevation.level4, + onClick = { homeNavController.popBackStack() }, + modifier = Modifier.padding(horizontal = 24.dp) + ) + }, + ) +} + +@Composable +private fun ProgramsText( + programs: List, + onProgramClicked: (String) -> Unit, +) { + val programsAnnotated = buildAnnotatedString { + programs.forEachIndexed { i, program -> + if (i > 0) append(", ") + withLink( + LinkAnnotation.Clickable( + tag = program.programId, + styles = TextLinkStyles( + style = SpanStyle( + color = HorizonColors.Text.body(), + fontStyle = HorizonTypography.labelMediumBold.fontStyle, + textDecoration = TextDecoration.Underline, + ) + ), + linkInteractionListener = { _ -> onProgramClicked(program.programId) } + ) + ) { + append(program.programName) + } + } + } + + // String resource can't work with annotated string so we need a temporary placeholder + val template = stringResource(R.string.learnScreen_partOfProgram, "__PROGRAMS__") + + val fullText = buildAnnotatedString { + val parts = template.split("__PROGRAMS__") + append(parts[0]) + append(programsAnnotated) + if (parts.size > 1) append(parts[1]) + } + + Text( + text = fullText, + style = HorizonTypography.labelMediumBold, + color = HorizonColors.Text.timestamp(), + modifier = Modifier + .semantics(mergeDescendants = true) {} + ) +} + +@Composable +private fun CourseProgress( + progress: Double, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + ProgressBarSmall( + progress = progress, + style = ProgressBarStyle.Institution, + showLabels = false, + modifier = Modifier.weight(1f) + ) + + HorizonSpace(SpaceSize.SPACE_8) + + Text( + text = stringResource(R.string.progressBar_percent, progress.roundToInt()), + style = HorizonTypography.p2, + color = HorizonColors.Surface.institution(), + ) + } +} + +@Composable +private fun EmptyCoursesMessage() { + Column { + Text( + stringResource(R.string.dashboardCourseListEmptyTitle), + style = HorizonTypography.h2, + color = HorizonColors.Text.body() + ) + + HorizonSpace(SpaceSize.SPACE_8) + + Text( + stringResource(R.string.dashboardCourseListEmptyMessage), + style = HorizonTypography.p1, + color = HorizonColors.Text.body() + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListUiState.kt new file mode 100644 index 0000000000..d554189371 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListUiState.kt @@ -0,0 +1,62 @@ +/* + * 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.dashboard.widget.course.list + +import android.content.Context +import androidx.annotation.StringRes +import com.instructure.horizon.R +import com.instructure.horizon.horizonui.platform.LoadingState + +data class DashboardCourseListUiState( + val loadingState: LoadingState = LoadingState(), + val courses: List = emptyList(), + val filterOptions: List = DashboardCourseListFilterOption.entries, + val selectedFilterOption: DashboardCourseListFilterOption = DashboardCourseListFilterOption.All, + val visibleCourseCount: Int = 10, + val onFilterOptionSelected: (DashboardCourseListFilterOption) -> Unit = {}, + val onRefresh: () -> Unit = {}, + val onShowMoreCourses: () -> Unit = {}, +) + +data class DashboardCourseListCourseState( + val parentPrograms: List, + val name: String, + val courseId: Long, + val progress: Double, +) + +data class DashboardCourseListParentProgramState( + val programName: String, + val programId: String, +) + +enum class DashboardCourseListFilterOption(@param:StringRes val labelRes: Int) { + All(R.string.dashboardCourseListAllCoursesFilterLabel), + NotStarted(R.string.dashboardCourseListNotStartedFilterLabel), + InProgress(R.string.dashboardCourseListInProgressFilterLabel), + Completed(R.string.dashboardCourseListCompletedFilterLabel); + + companion object { + fun fromLabelRes(@StringRes labelRes: Int): DashboardCourseListFilterOption { + return entries.firstOrNull { it.labelRes == labelRes } ?: All + } + + fun fromLabel(context: Context, label: String): DashboardCourseListFilterOption { + return entries.firstOrNull { context.getString(it.labelRes) == label } ?: All + } + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListViewModel.kt new file mode 100644 index 0000000000..edafc77bec --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListViewModel.kt @@ -0,0 +1,143 @@ +/* + * 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.dashboard.widget.course.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.utils.weave.catch +import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.horizonui.platform.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class DashboardCourseListViewModel @Inject constructor( + private val repository: DashboardCourseListRepository +): ViewModel() { + private val _uiState = MutableStateFlow( + DashboardCourseListUiState( + loadingState = LoadingState( + onRefresh = ::onRefresh, + onSnackbarDismiss = ::onDismissSnackbar + ), + onFilterOptionSelected = ::onFilterOptionSelected, + onShowMoreCourses = ::onShowMoreCourses + ) + ) + val uiState = _uiState.asStateFlow() + private var allCourses: List = emptyList() + private val pageSize: Int = 10 + + init { + loadData() + } + + private fun loadData() { + viewModelScope.tryLaunch { + _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true)) } + fetchData() + _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false)) } + } catch { + _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false)) } + } + } + + private suspend fun fetchData(forceRefresh: Boolean = false) { + val courses = repository.getCourses(forceRefresh) + val programs = repository.getPrograms(forceRefresh) + val courseStates = courses.map { course -> + DashboardCourseListCourseState( + parentPrograms = programs.filter { program -> + program.sortedRequirements.any { requirement -> + requirement.courseId == course.courseId + } + }.map { program -> + DashboardCourseListParentProgramState( + programName = program.name, + programId = program.id + ) + }, + name = course.courseName, + courseId = course.courseId, + progress = course.progress + ) + }.sortedByDescending { course -> + course.progress.run { if (this == 100.0) -1.0 else this } // Active courses first, then completed courses + } + + allCourses = courseStates + _uiState.update { + it.copy( + courses = filterCourses(it.selectedFilterOption, courseStates), + visibleCourseCount = pageSize + ) + } + } + + private fun filterCourses( + filterOption: DashboardCourseListFilterOption, + allCourses: List + ): List { + return when (filterOption) { + DashboardCourseListFilterOption.All -> { + allCourses + } + DashboardCourseListFilterOption.NotStarted -> { + allCourses.filter { it.progress == 0.0 } + } + DashboardCourseListFilterOption.InProgress -> { + allCourses.filter { it.progress > 0.0 && it.progress < 100.0 } + } + DashboardCourseListFilterOption.Completed -> { + allCourses.filter { it.progress == 100.0 } + } + } + } + + private fun onRefresh() { + viewModelScope.tryLaunch { + _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = true)) } + fetchData(true) + _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = false)) } + } catch { + _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = false)) } + } + } + + private fun onFilterOptionSelected(filterOption: DashboardCourseListFilterOption) { + _uiState.update { + it.copy( + courses = filterCourses(filterOption, allCourses), + selectedFilterOption = filterOption, + visibleCourseCount = pageSize + ) + } + } + + private fun onShowMoreCourses() { + _uiState.update { + it.copy(visibleCourseCount = it.visibleCourseCount + pageSize) + } + } + + private fun onDismissSnackbar() { + _uiState.update { it.copy(loadingState = it.loadingState.copy(snackbarMessage = null)) } + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/DashboardMyProgressWidget.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/DashboardMyProgressWidget.kt index 6ac669b2e5..8b4f6f839d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/DashboardMyProgressWidget.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/DashboardMyProgressWidget.kt @@ -16,20 +16,19 @@ */ package com.instructure.horizon.features.dashboard.widget.myprogress -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardWidgetCardError +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.dashboard.widget.myprogress.card.DashboardMyProgressCardContent +import com.instructure.horizon.features.dashboard.widget.myprogress.card.DashboardMyProgressCardState import com.instructure.horizon.horizonui.foundation.HorizonColors import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -38,6 +37,7 @@ import kotlinx.coroutines.flow.update fun DashboardMyProgressWidget( shouldRefresh: Boolean, refreshState: MutableStateFlow>, + pageState: DashboardWidgetPageState, modifier: Modifier = Modifier ) { val viewModel = hiltViewModel() @@ -52,26 +52,23 @@ fun DashboardMyProgressWidget( } } - DashboardMyProgressSection(state, modifier) + DashboardMyProgressSection(state, pageState, modifier) } @Composable fun DashboardMyProgressSection( state: DashboardMyProgressUiState, + pageState: DashboardWidgetPageState, modifier: Modifier = Modifier ) { when (state.state) { DashboardItemState.LOADING -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - DashboardMyProgressCardContent( - state.cardState, - true, - modifier - ) - } + DashboardMyProgressCardContent( + DashboardMyProgressCardState.Loading, + true, + pageState, + modifier + ) } DashboardItemState.ERROR -> { DashboardWidgetCardError( @@ -79,21 +76,18 @@ fun DashboardMyProgressSection( R.drawable.trending_up, HorizonColors.PrimitivesSky.sky12, false, + pageState, { state.onRefresh {} }, modifier = modifier ) } DashboardItemState.SUCCESS -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(), - ) { - DashboardMyProgressCardContent( - state.cardState, - false, - modifier - ) - } + DashboardMyProgressCardContent( + state.cardState, + false, + pageState, + modifier + ) } } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/card/DashboardMyProgressCardContent.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/card/DashboardMyProgressCardContent.kt index 750752613d..6f81951196 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/card/DashboardMyProgressCardContent.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/card/DashboardMyProgressCardContent.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.widget.DashboardWidgetCard +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.horizonui.animation.shimmerEffect import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonTypography @@ -44,12 +45,14 @@ import com.instructure.horizon.horizonui.foundation.HorizonTypography fun DashboardMyProgressCardContent( state: DashboardMyProgressCardState, isLoading: Boolean, + pageState: DashboardWidgetPageState, modifier: Modifier = Modifier, ) { DashboardWidgetCard( stringResource(R.string.dashboardMyProgressTitle), R.drawable.trending_up, HorizonColors.PrimitivesSky.sky12, + pageState = pageState, useMinWidth = false, isLoading = isLoading, modifier = modifier @@ -104,7 +107,8 @@ private fun DashboardMyProgressCardContentPreview() { state = DashboardMyProgressCardState( moduleCountCompleted = 24 ), - false + false, + DashboardWidgetPageState(1, 2) ) } @@ -115,7 +119,8 @@ private fun DashboardMyProgressCardContentZeroPreview() { state = DashboardMyProgressCardState( moduleCountCompleted = 0 ), - false + false, + DashboardWidgetPageState(1, 2) ) } @@ -126,6 +131,7 @@ private fun DashboardMyProgressLoadingPreview() { state = DashboardMyProgressCardState( moduleCountCompleted = 0 ), - isLoading = true + isLoading = true, + DashboardWidgetPageState(1, 2) ) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/card/DashboardMyProgressCardState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/card/DashboardMyProgressCardState.kt index 892724666b..1c4b53ba0a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/card/DashboardMyProgressCardState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/myprogress/card/DashboardMyProgressCardState.kt @@ -18,4 +18,8 @@ package com.instructure.horizon.features.dashboard.widget.myprogress.card data class DashboardMyProgressCardState( val moduleCountCompleted: Int = 0 -) +) { + companion object { + val Loading = DashboardMyProgressCardState(5) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsWidget.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsWidget.kt index f4ba223bee..7d83c9783f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsWidget.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsWidget.kt @@ -24,12 +24,14 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardWidgetCardError +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.DashboardSkillHighlightsCardContent +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.DashboardSkillHighlightsCardState import com.instructure.horizon.horizonui.foundation.HorizonColors import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -65,7 +67,7 @@ fun DashboardSkillHighlightsSection( when (state.state) { DashboardItemState.LOADING -> { DashboardSkillHighlightsCardContent( - state.cardState, + DashboardSkillHighlightsCardState.Loading, homeNavController, true, modifier.padding(horizontal = 24.dp), @@ -77,6 +79,7 @@ fun DashboardSkillHighlightsSection( R.drawable.hub, HorizonColors.PrimitivesGreen.green12(), false, + DashboardWidgetPageState.Empty, { state.onRefresh {} }, modifier = modifier.padding(horizontal = 24.dp) ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardContent.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardContent.kt index 1a74cf7c34..fd4e72a0a4 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardContent.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardContent.kt @@ -19,12 +19,15 @@ package com.instructure.horizon.features.dashboard.widget.skillhighlights.card import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext @@ -44,6 +47,7 @@ import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonSpace import com.instructure.horizon.horizonui.foundation.HorizonTypography import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.isWideLayout import com.instructure.horizon.horizonui.molecules.Pill import com.instructure.horizon.horizonui.molecules.PillCase import com.instructure.horizon.horizonui.molecules.PillSize @@ -67,7 +71,6 @@ fun DashboardSkillHighlightsCardContent( ) { if (state.skills.isEmpty()) { Column { - HorizonSpace(SpaceSize.SPACE_8) Text( text = stringResource(R.string.dashboardSkillHighlightsNoDataTitle), style = HorizonTypography.h4, @@ -84,20 +87,47 @@ fun DashboardSkillHighlightsCardContent( } } else { HorizonSpace(SpaceSize.SPACE_8) - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - state.skills.forEach { skill -> - SkillCard( - skill, - skill.proficiencyLevel.opacity(), - homeNavController, - modifier = Modifier.shimmerEffect( - isLoading, - backgroundColor = HorizonColors.PrimitivesGreen.green12().copy(alpha = 0.8f), - shimmerColor = HorizonColors.PrimitivesGreen.green12().copy(alpha = 0.5f) - ) - ) + + BoxWithConstraints { + if (this.isWideLayout) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + state.skills.forEach { skill -> + SkillCard( + skill, + skill.proficiencyLevel.opacity(), + homeNavController, + modifier = Modifier + .weight(1f) + .shimmerEffect( + isLoading, + backgroundColor = HorizonColors.PrimitivesGreen.green12().copy(alpha = 0.8f), + shimmerColor = HorizonColors.PrimitivesGreen.green12().copy(alpha = 0.5f) + ) + ) + } + } + } else { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + state.skills.forEach { skill -> + SkillCard( + skill, + skill.proficiencyLevel.opacity(), + homeNavController, + modifier = Modifier + .fillMaxWidth() + .shimmerEffect( + isLoading, + backgroundColor = HorizonColors.PrimitivesGreen.green12().copy(alpha = 0.8f), + shimmerColor = HorizonColors.PrimitivesGreen.green12().copy(alpha = 0.5f) + ) + ) + } + } } } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardState.kt index be8da4680e..0d70e27ac6 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardState.kt @@ -21,12 +21,25 @@ import com.instructure.horizon.R data class DashboardSkillHighlightsCardState( val skills: List = emptyList() -) +) { + companion object { + val Loading = DashboardSkillHighlightsCardState( + skills = List(3) { SkillHighlight.Loading } + ) + } +} data class SkillHighlight( val name: String, val proficiencyLevel: SkillHighlightProficiencyLevel -) +) { + companion object { + val Loading = SkillHighlight( + name = "Lorem Ipsum Dolor", + proficiencyLevel = SkillHighlightProficiencyLevel.BEGINNER + ) + } +} enum class SkillHighlightProficiencyLevel( @StringRes val skillProficiencyLevelRes: Int, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidget.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidget.kt index 8b46c1d732..1c56ea1a08 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidget.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidget.kt @@ -22,12 +22,14 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardWidgetCardError +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardContent +import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardState import com.instructure.horizon.horizonui.foundation.HorizonColors import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -37,6 +39,7 @@ fun DashboardSkillOverviewWidget( homeNavController: NavHostController, shouldRefresh: Boolean, refreshState: MutableStateFlow>, + pageState: DashboardWidgetPageState, modifier: Modifier = Modifier ) { val viewModel = hiltViewModel() @@ -51,21 +54,23 @@ fun DashboardSkillOverviewWidget( } } - DashboardSkillOverviewSection(state, homeNavController, modifier) + DashboardSkillOverviewSection(state, pageState, homeNavController, modifier) } @Composable fun DashboardSkillOverviewSection( state: DashboardSkillOverviewUiState, + pageState: DashboardWidgetPageState, homeNavController: NavHostController, modifier: Modifier = Modifier ) { when (state.state) { DashboardItemState.LOADING -> { DashboardSkillOverviewCardContent( - state.cardState, + DashboardSkillOverviewCardState.Loading, homeNavController, true, + pageState, modifier ) } @@ -75,6 +80,7 @@ fun DashboardSkillOverviewSection( R.drawable.hub, HorizonColors.PrimitivesGreen.green12(), false, + pageState, { state.onRefresh {} }, modifier = modifier ) @@ -84,6 +90,7 @@ fun DashboardSkillOverviewSection( state.cardState, homeNavController, false, + pageState, modifier ) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardContent.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardContent.kt index 77567167d4..5c2fef78de 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardContent.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardContent.kt @@ -38,6 +38,7 @@ import androidx.navigation.compose.rememberNavController import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.widget.DashboardWidgetCard +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.home.HomeNavigationRoute import com.instructure.horizon.horizonui.animation.shimmerEffect import com.instructure.horizon.horizonui.foundation.HorizonColors @@ -48,12 +49,14 @@ fun DashboardSkillOverviewCardContent( state: DashboardSkillOverviewCardState, homeNavController: NavHostController, isLoading: Boolean, + pageState: DashboardWidgetPageState, modifier: Modifier = Modifier, ) { DashboardWidgetCard( title = stringResource(R.string.dashboardSkillOverviewTitle), iconRes = R.drawable.hub, widgetColor = HorizonColors.PrimitivesGreen.green12(), + pageState = pageState, isLoading = isLoading, useMinWidth = false, onClick = { @@ -114,7 +117,8 @@ private fun DashboardSkillOverviewCardContentPreview() { DashboardSkillOverviewCardContent( state = DashboardSkillOverviewCardState(completedSkillCount = 24), rememberNavController(), - false + false, + DashboardWidgetPageState(1, 2) ) } @@ -125,7 +129,8 @@ private fun DashboardSkillOverviewCardContentNoDataPreview() { DashboardSkillOverviewCardContent( state = DashboardSkillOverviewCardState(completedSkillCount = 0), rememberNavController(), - false + false, + DashboardWidgetPageState(1, 2) ) } @@ -136,7 +141,8 @@ private fun DashboardSkillOverviewLoadingPreview() { DashboardSkillOverviewCardContent( state = DashboardSkillOverviewCardState(completedSkillCount = 0), rememberNavController(), - isLoading = true + isLoading = true, + DashboardWidgetPageState(1, 2) ) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardState.kt index 8fabe83d8b..4fc816becc 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardState.kt @@ -18,4 +18,8 @@ package com.instructure.horizon.features.dashboard.widget.skilloverview.card data class DashboardSkillOverviewCardState( val completedSkillCount: Int = 0 -) +) { + companion object { + val Loading = DashboardSkillOverviewCardState(5) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/DashboardTimeSpentWidget.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/DashboardTimeSpentWidget.kt index 7d15c923fe..ee15812d5d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/DashboardTimeSpentWidget.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/DashboardTimeSpentWidget.kt @@ -16,20 +16,19 @@ */ package com.instructure.horizon.features.dashboard.widget.timespent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardWidgetCardError +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.features.dashboard.widget.timespent.card.DashboardTimeSpentCardContent +import com.instructure.horizon.features.dashboard.widget.timespent.card.DashboardTimeSpentCardState import com.instructure.horizon.horizonui.foundation.HorizonColors import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -38,6 +37,7 @@ import kotlinx.coroutines.flow.update fun DashboardTimeSpentWidget( shouldRefresh: Boolean, refreshState: MutableStateFlow>, + pageState: DashboardWidgetPageState, modifier: Modifier = Modifier ) { val viewModel = hiltViewModel() @@ -52,17 +52,23 @@ fun DashboardTimeSpentWidget( } } - DashboardTimeSpentSection(state, modifier) + DashboardTimeSpentSection(state, pageState, modifier) } @Composable fun DashboardTimeSpentSection( state: DashboardTimeSpentUiState, + pageState: DashboardWidgetPageState, modifier: Modifier = Modifier ) { when (state.state) { DashboardItemState.LOADING -> { - DashboardTimeSpentCardContent(state.cardState, true, modifier) + DashboardTimeSpentCardContent( + DashboardTimeSpentCardState.Loading, + true, + pageState, + modifier + ) } DashboardItemState.ERROR -> { DashboardWidgetCardError( @@ -70,17 +76,18 @@ fun DashboardTimeSpentSection( R.drawable.schedule, HorizonColors.PrimitivesHoney.honey12(), false, + pageState, { state.onRefresh {} }, modifier = modifier ) } DashboardItemState.SUCCESS -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - DashboardTimeSpentCardContent(state.cardState, false, modifier) - } + DashboardTimeSpentCardContent( + state.cardState, + false, + pageState, + modifier + ) } } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardContent.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardContent.kt index 41c2e4a935..12dc48d1e1 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardContent.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardContent.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -42,6 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.widget.DashboardWidgetCard +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetPageState import com.instructure.horizon.horizonui.animation.shimmerEffect import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonSpace @@ -56,6 +58,7 @@ import com.instructure.horizon.horizonui.organisms.inputs.singleselect.SingleSel fun DashboardTimeSpentCardContent( state: DashboardTimeSpentCardState, isLoading: Boolean, + pageState: DashboardWidgetPageState, modifier: Modifier = Modifier, ) { DashboardWidgetCard( @@ -63,6 +66,7 @@ fun DashboardTimeSpentCardContent( R.drawable.schedule, HorizonColors.PrimitivesHoney.honey12(), modifier, + pageState, isLoading, false ) { @@ -133,6 +137,7 @@ fun DashboardTimeSpentCardContent( modifier = Modifier .shimmerEffect(isLoading) .focusable() + .widthIn(min = 100.dp) ) } } @@ -234,7 +239,8 @@ private fun DashboardTimeSpentCardContentPreview() { ), selectedCourseId = null ), - isLoading = false + isLoading = false, + DashboardWidgetPageState(1, 2) ) } @@ -251,7 +257,8 @@ private fun DashboardTimeSpentCardSelectedContentPreview() { ), selectedCourseId = 1 ), - isLoading = false + isLoading = false, + DashboardWidgetPageState(1, 2) ) } @@ -266,7 +273,8 @@ private fun DashboardTimeSpentCardContentSingleCourseHoursPreview() { ), selectedCourseId = null ), - isLoading = false + isLoading = false, + DashboardWidgetPageState(1, 2) ) } @@ -281,7 +289,8 @@ private fun DashboardTimeSpentCardContentSingleCourseMinutesPreview() { ), selectedCourseId = null ), - isLoading = false + isLoading = false, + DashboardWidgetPageState(1, 2) ) } @@ -297,7 +306,8 @@ private fun DashboardTimeSpentCardContentSingleCourseCombinedPreview() { ), selectedCourseId = null ), - isLoading = false + isLoading = false, + DashboardWidgetPageState(1, 2) ) } @@ -311,7 +321,8 @@ private fun DashboardTimeSpentCardEmptyContentPreview() { ), selectedCourseId = null ), - isLoading = false + isLoading = false, + DashboardWidgetPageState(1, 2) ) } @@ -320,6 +331,7 @@ private fun DashboardTimeSpentCardEmptyContentPreview() { private fun DashboardTimeSpentCardLoadingPreview() { DashboardTimeSpentCardContent( state = DashboardTimeSpentCardState(), - isLoading = true + isLoading = true, + DashboardWidgetPageState(1, 2) ) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardState.kt index 198c52b23d..e1d0b4dd04 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/timespent/card/DashboardTimeSpentCardState.kt @@ -22,7 +22,11 @@ data class DashboardTimeSpentCardState( val courses: List = emptyList(), val selectedCourseId: Long? = null, val onCourseSelected: (String?) -> Unit = {} -) +) { + companion object { + val Loading = DashboardTimeSpentCardState(hours = 5, minutes = 30,) + } +} data class CourseOption( val id: Long, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeNavigation.kt index 024cf0e707..39b6f9a5df 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeNavigation.kt @@ -19,7 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -28,12 +28,17 @@ import androidx.navigation.navArgument import com.instructure.horizon.features.account.navigation.accountNavigation import com.instructure.horizon.features.dashboard.DashboardScreen import com.instructure.horizon.features.dashboard.DashboardViewModel +import com.instructure.horizon.features.dashboard.widget.course.list.DashboardCourseListScreen +import com.instructure.horizon.features.dashboard.widget.course.list.DashboardCourseListViewModel import com.instructure.horizon.features.learn.LearnScreen import com.instructure.horizon.features.learn.LearnViewModel import com.instructure.horizon.features.skillspace.SkillspaceScreen import com.instructure.horizon.features.skillspace.SkillspaceViewModel -import com.instructure.horizon.horizonui.animation.mainEnterTransition -import com.instructure.horizon.horizonui.animation.mainExitTransition +import com.instructure.horizon.horizonui.animation.NavigationTransitionAnimation +import com.instructure.horizon.horizonui.animation.enterTransition +import com.instructure.horizon.horizonui.animation.exitTransition +import com.instructure.horizon.horizonui.animation.popEnterTransition +import com.instructure.horizon.horizonui.animation.popExitTransition import com.instructure.horizon.horizonui.showroom.ShowroomContent import com.instructure.horizon.horizonui.showroom.ShowroomItem import com.instructure.horizon.horizonui.showroom.showroomItems @@ -45,6 +50,7 @@ const val PROGRAM_PREFIX = "program_" @Serializable sealed class HomeNavigationRoute(val route: String) { data object Dashboard : HomeNavigationRoute("dashboard") + data object CourseList: HomeNavigationRoute("courses") data object Learn : HomeNavigationRoute("learn?learningItemId={learningItemId}") { fun withCourse(courseId: Long? = null) = if (courseId != null) "learn?learningItemId=$COURSE_PREFIX$courseId" else "learn" @@ -61,10 +67,10 @@ sealed class HomeNavigationRoute(val route: String) { fun HomeNavigation(navController: NavHostController, mainNavController: NavHostController, modifier: Modifier = Modifier) { NavHost( navController, - enterTransition = { mainEnterTransition }, - exitTransition = { mainExitTransition }, - popEnterTransition = { mainEnterTransition }, - popExitTransition = { mainExitTransition }, + enterTransition = { enterTransition(NavigationTransitionAnimation.SCALE) }, + exitTransition = { exitTransition(NavigationTransitionAnimation.SCALE) }, + popEnterTransition = { popEnterTransition(NavigationTransitionAnimation.SCALE) }, + popExitTransition = { popExitTransition(NavigationTransitionAnimation.SCALE) }, startDestination = HomeNavigationRoute.Dashboard.route, modifier = modifier ) { composable(HomeNavigationRoute.Dashboard.route) { @@ -88,6 +94,11 @@ fun HomeNavigation(navController: NavHostController, mainNavController: NavHostC val uiState by viewModel.uiState.collectAsState() SkillspaceScreen(uiState) } + composable(HomeNavigationRoute.CourseList.route) { + val viewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + DashboardCourseListScreen(uiState, navController) + } accountNavigation(navController) showroomItems.filterIsInstance() diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeScreen.kt index 8c3e4be2a5..448a40a3e3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/home/HomeScreen.kt @@ -129,7 +129,11 @@ private fun BottomNavigationBar( saveState = true } launchSingleTop = true - restoreState = true + + // Do not restore screen state when navigating to Dashboard screen + // Restore when navigating between other screens + restoreState = item.route != HomeNavigationRoute.Dashboard.route || + (item.route == HomeNavigationRoute.Dashboard.route && currentDestination?.route == HomeNavigationRoute.Dashboard.route) } }) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt index b0af64c093..7e63437e3e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt @@ -53,7 +53,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.instructure.canvasapi2.utils.ContextKeeper diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/navigation/HorizonInboxNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/navigation/HorizonInboxNavigation.kt index 71a05eee05..63d2f67352 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/navigation/HorizonInboxNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/navigation/HorizonInboxNavigation.kt @@ -16,12 +16,10 @@ */ package com.instructure.horizon.features.inbox.navigation -import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavBackStackEntry +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable @@ -39,12 +37,9 @@ import com.instructure.horizon.features.inbox.list.HorizonInboxListScreen import com.instructure.horizon.features.inbox.list.HorizonInboxListViewModel import com.instructure.horizon.horizonui.animation.enterTransition import com.instructure.horizon.horizonui.animation.exitTransition -import com.instructure.horizon.horizonui.animation.mainEnterTransition -import com.instructure.horizon.horizonui.animation.mainExitTransition import com.instructure.horizon.horizonui.animation.popEnterTransition import com.instructure.horizon.horizonui.animation.popExitTransition import com.instructure.horizon.navigation.MainNavigationRoute -import com.instructure.pandautils.utils.orDefault fun NavGraphBuilder.horizonInboxNavigation( navController: NavHostController, @@ -55,10 +50,10 @@ fun NavGraphBuilder.horizonInboxNavigation( ) { composable( HorizonInboxRoute.InboxList.route, - enterTransition = { if (isComposeTransition()) mainEnterTransition else enterTransition }, - exitTransition = { if (isComposeTransition()) mainExitTransition else exitTransition }, - popEnterTransition = { if (isComposeTransition()) mainEnterTransition else popEnterTransition }, - popExitTransition = { if (isComposeTransition()) mainExitTransition else popExitTransition }, + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() }, ) { val viewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsState() @@ -66,10 +61,6 @@ fun NavGraphBuilder.horizonInboxNavigation( } composable( HorizonInboxRoute.InboxDetails.route, - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, arguments = listOf( navArgument(HorizonInboxRoute.InboxDetails.TYPE) { type = androidx.navigation.NavType.StringType @@ -90,10 +81,6 @@ fun NavGraphBuilder.horizonInboxNavigation( } composable( HorizonInboxRoute.InboxCompose.route, - enterTransition = { mainEnterTransition }, - exitTransition = { mainExitTransition }, - popEnterTransition = { mainEnterTransition }, - popExitTransition = { mainExitTransition }, ) { val viewModel: HorizonInboxComposeViewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsState() @@ -107,10 +94,6 @@ fun NavGraphBuilder.horizonInboxNavigation( // Conversation Details from deeplink composable( HorizonInboxRoute.InboxDetailsDeepLink.route, - enterTransition = { mainEnterTransition }, - exitTransition = { mainExitTransition }, - popEnterTransition = { mainEnterTransition }, - popExitTransition = { mainExitTransition }, arguments = listOf( navArgument(HorizonInboxRoute.InboxDetails.ID) { type = androidx.navigation.NavType.LongType @@ -135,10 +118,6 @@ fun NavGraphBuilder.horizonInboxNavigation( // Announcement Details from deeplink composable( HorizonInboxRoute.CourseAnnouncementDetailsDeepLink.route, - enterTransition = { mainEnterTransition }, - exitTransition = { mainExitTransition }, - popEnterTransition = { mainEnterTransition }, - popExitTransition = { mainExitTransition }, arguments = listOf( navArgument(HorizonInboxRoute.InboxDetails.ID) { type = androidx.navigation.NavType.LongType @@ -167,13 +146,4 @@ fun NavGraphBuilder.horizonInboxNavigation( } } } -} - -private fun AnimatedContentTransitionScope.isComposeTransition(): Boolean { - return this.targetState.destination.route - ?.startsWith(HorizonInboxRoute.InboxCompose.route) - .orDefault() || - this.initialState.destination.route - ?.startsWith(HorizonInboxRoute.InboxCompose.route) - .orDefault() } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnScreen.kt index 89c4de046d..ac7373934d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnScreen.kt @@ -62,7 +62,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsScreen.kt index c065409c4e..8dbe299a19 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/lti/CourseToolsScreen.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.instructure.horizon.R diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/note/CourseNotesScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/note/CourseNotesScreen.kt index f58171c751..9a3cd7e3a2 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/note/CourseNotesScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/note/CourseNotesScreen.kt @@ -19,11 +19,9 @@ package com.instructure.horizon.features.learn.course.note import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import com.instructure.horizon.features.notebook.NotebookScreen import com.instructure.horizon.features.notebook.NotebookViewModel @@ -35,10 +33,10 @@ fun CourseNotesScreen( modifier: Modifier = Modifier ) { val viewModel: NotebookViewModel = hiltViewModel() - val state by viewModel.uiState.collectAsState() LaunchedEffect(courseId) { - viewModel.updateCourseId(courseId) + viewModel.updateFilters(courseId) + viewModel.updateScreenState(showNoteTypeFilter = true, showCourseFilter = false, showTopBar = false) } Box( @@ -47,7 +45,7 @@ fun CourseNotesScreen( ) { NotebookScreen( mainNavController, - state + viewModel ) } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressScreen.kt index 3f0ac5602a..789d968aff 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressScreen.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import com.instructure.canvasapi2.utils.ContextKeeper diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/score/CourseScoreScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/score/CourseScoreScreen.kt index 13e0b2f4bd..9a401b0e27 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/score/CourseScoreScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/score/CourseScoreScreen.kt @@ -54,7 +54,7 @@ import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.AssignmentGroup diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt index a526ca2f46..e681e4c029 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -76,7 +75,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -101,7 +100,7 @@ import com.instructure.horizon.features.moduleitemsequence.content.lti.ExternalT import com.instructure.horizon.features.moduleitemsequence.content.page.PageDetailsContentScreen import com.instructure.horizon.features.moduleitemsequence.content.page.PageDetailsViewModel import com.instructure.horizon.features.moduleitemsequence.progress.ProgressScreen -import com.instructure.horizon.features.notebook.NotebookBottomDialog +import com.instructure.horizon.features.notebook.navigation.NotebookRoute import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius import com.instructure.horizon.horizonui.foundation.HorizonElevation @@ -122,14 +121,12 @@ import com.instructure.horizon.horizonui.molecules.PillType import com.instructure.horizon.horizonui.molecules.Spinner import com.instructure.horizon.horizonui.molecules.SpinnerSize import com.instructure.horizon.horizonui.platform.LoadingStateWrapper -import com.instructure.horizon.navigation.MainNavigationRoute import com.instructure.pandautils.compose.modifiers.conditional import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.ViewStyler import com.instructure.pandautils.utils.getActivityOrNull import com.instructure.pandautils.utils.orDefault -import kotlinx.coroutines.launch import kotlin.math.abs @Composable @@ -153,7 +150,18 @@ fun ModuleItemSequenceScreen(mainNavController: NavHostController, uiState: Modu onPreviousClick = uiState.onPreviousClick, onAssignmentToolsClick = uiState.onAssignmentToolsClick, onAiAssistClick = { uiState.updateShowAiAssist(true) }, - onNotebookClick = { uiState.updateShowNotebook(true) }, + onNotebookClick = { + mainNavController.navigate( + NotebookRoute.Notebook.route( + uiState.courseId.toString(), + uiState.objectTypeAndId.first, + uiState.objectTypeAndId.second, + true, + false, + true + ) + ) + }, notebookEnabled = uiState.notebookButtonEnabled, aiAssistEnabled = uiState.aiAssistButtonEnabled, hasUnreadComments = uiState.hasUnreadComments @@ -166,22 +174,6 @@ fun ModuleItemSequenceScreen(mainNavController: NavHostController, uiState: Modu onDismiss = { uiState.updateShowAiAssist(false) }, ) } - if (uiState.showNotebook) { - NotebookBottomDialog( - uiState.courseId, - uiState.objectTypeAndId, - { snackbarMessage, onDismiss -> - scope.launch { - if (snackbarMessage != null) { - val result = snackbarHostState.showSnackbar(snackbarMessage) - if (result == SnackbarResult.Dismissed) { - onDismiss() - } - } - } }, - { uiState.updateShowNotebook(false) } - ) - } ModuleItemSequenceContent(uiState = uiState, mainNavController = mainNavController, onBackPressed = { mainNavController.popBackStack() }) @@ -646,7 +638,6 @@ private fun ModuleItemSequenceScreenPreview() { moduleItemContent = ModuleItemContent.Assignment(courseId = 1, assignmentId = 1L) ), updateShowAiAssist = {}, - updateShowNotebook = {}, ) ) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceUiState.kt index 0fee607201..e2fe68f661 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceUiState.kt @@ -36,10 +36,8 @@ data class ModuleItemSequenceUiState( val assignmentToolsOpened: () -> Unit = {}, val showAiAssist: Boolean = false, val aiAssistButtonEnabled: Boolean = false, - val showNotebook: Boolean = false, val notebookButtonEnabled: Boolean = false, val updateShowAiAssist: (Boolean) -> Unit, - val updateShowNotebook: (Boolean) -> Unit, val objectTypeAndId: Pair = Pair("", ""), val updateObjectTypeAndId: (Pair) -> Unit = {}, val hasUnreadComments: Boolean = false, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt index 0afcccbe11..a9189d1e99 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt @@ -85,7 +85,6 @@ class ModuleItemSequenceViewModel @Inject constructor( onAssignmentToolsClick = ::onAssignmentToolsClicked, assignmentToolsOpened = ::assignmentToolsOpened, updateShowAiAssist = ::updateShowAiAssist, - updateShowNotebook = ::updateShowNotebook, updateObjectTypeAndId = ::updateNotebookObjectTypeAndId, updateAiAssistContext = ::updateAiAssistContext, ) @@ -550,10 +549,6 @@ class ModuleItemSequenceViewModel @Inject constructor( _uiState.update { it.copy(showAiAssist = show) } } - private fun updateShowNotebook(show: Boolean) { - _uiState.update { it.copy(showNotebook = show) } - } - private fun updateNotebookObjectTypeAndId(objectTypeAndId: Pair) { _uiState.update { it.copy( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsScreen.kt index bad175585c..b14fcf99d6 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsScreen.kt @@ -36,9 +36,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,7 +47,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsContentScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsContentScreen.kt index 25e20ddd92..e94aaf919d 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsContentScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsContentScreen.kt @@ -30,9 +30,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition import com.instructure.horizon.features.aiassistant.common.model.AiAssistContextSource import com.instructure.horizon.features.notebook.common.webview.ComposeNotesHighlightingCanvasWebView import com.instructure.horizon.features.notebook.common.webview.NotesCallback @@ -97,7 +94,7 @@ fun PageDetailsContentScreen( launchInternalWebViewFragment = { url -> activity?.launchCustomTab(url, ThemePrefs.brandColor) } ), notesCallback = NotesCallback( - onNoteSelected = { noteId, noteType, selectedText, userComment, startContainer, startOffset, endContainer, endOffset, textSelectionStart, textSelectionEnd -> + onNoteSelected = { noteId, noteType, selectedText, userComment, startContainer, startOffset, endContainer, endOffset, textSelectionStart, textSelectionEnd, updatedAt -> mainNavController.navigate( NotebookRoute.EditNotebook( noteId = noteId, @@ -109,45 +106,27 @@ fun PageDetailsContentScreen( highlightedText = selectedText, userComment = userComment, textSelectionStart = textSelectionStart, - textSelectionEnd = textSelectionEnd + textSelectionEnd = textSelectionEnd, + updatedAt = updatedAt ) ) }, onNoteAdded = { selectedText, noteType, startContainer, startOffset, endContainer, endOffset, textSelectionStart, textSelectionEnd -> - if (noteType == null) { - mainNavController.navigate( - NotebookRoute.AddNotebook( - courseId = uiState.courseId.toString(), - objectType = "Page", - objectId = uiState.pageId.toString(), - highlightedTextStartOffset = startOffset, - highlightedTextEndOffset = endOffset, - highlightedTextStartContainer = startContainer, - highlightedTextEndContainer = endContainer, - highlightedText = selectedText, - textSelectionStart = textSelectionStart, - textSelectionEnd = textSelectionEnd, - noteType = noteType - ) - ) - } else { - uiState.addNote( - NoteHighlightedData( - selectedText = selectedText, - range = NoteHighlightedDataRange( - startOffset = startOffset, - endOffset = endOffset, - startContainer = startContainer, - endContainer = endContainer - ), - textPosition = NoteHighlightedDataTextPosition( - start = textSelectionStart, - end = textSelectionEnd - ) - ), - noteType + mainNavController.navigate( + NotebookRoute.AddNotebook( + courseId = uiState.courseId.toString(), + objectType = "Page", + objectId = uiState.pageId.toString(), + highlightedTextStartOffset = startOffset, + highlightedTextEndOffset = endOffset, + highlightedTextStartContainer = startContainer, + highlightedTextEndContainer = endContainer, + highlightedText = selectedText, + textSelectionStart = textSelectionStart, + textSelectionEnd = textSelectionEnd, + noteType = noteType ) - } + ) } ) ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookDialog.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookDialog.kt deleted file mode 100644 index 87954dd93b..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookDialog.kt +++ /dev/null @@ -1,44 +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.notebook - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import com.instructure.horizon.features.notebook.navigation.NotebookDialogNavigation -import com.instructure.horizon.horizonui.foundation.HorizonColors - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NotebookBottomDialog( - courseId: Long, - objectFilter: Pair, - onShowSnackbar: (String?, () -> Unit) -> Unit, - onDismiss: () -> Unit -) { - val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - ModalBottomSheet( - containerColor = HorizonColors.Surface.pagePrimary(), - onDismissRequest = { onDismiss() }, - dragHandle = null, - sheetState = bottomSheetState, - ) { - NotebookDialogNavigation(courseId, objectFilter, onDismiss, onShowSnackbar) - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookRepository.kt index ecd66a204b..d3240e99ce 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookRepository.kt @@ -17,7 +17,10 @@ package com.instructure.horizon.features.notebook import com.apollographql.apollo.api.Optional +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.horizon.features.notebook.common.model.NotebookType import com.instructure.redwood.QueryNotesQuery import com.instructure.redwood.type.LearningObjectFilter @@ -28,6 +31,8 @@ import javax.inject.Inject class NotebookRepository @Inject constructor( private val redwoodApiManager: RedwoodApiManager, + private val horizonGetCoursesManager: HorizonGetCoursesManager, + private val apiPrefs: ApiPrefs, ) { suspend fun getNotes( after: String? = null, @@ -36,7 +41,8 @@ class NotebookRepository @Inject constructor( filterType: NotebookType? = null, courseId: Long? = null, objectTypeAndId: Pair? = null, - orderDirection: OrderDirection? = null + orderDirection: OrderDirection? = null, + forceNetwork: Boolean = false ): QueryNotesQuery.Notes { val filterInput = NoteFilterInput( reactions = if (filterType != null) { @@ -67,16 +73,29 @@ class NotebookRepository @Inject constructor( lastN = itemCount, before = before, filter = filterInput, - orderBy = orderByInput + orderBy = orderByInput, + forceNetwork = forceNetwork ) } else { redwoodApiManager.getNotes( firstN = itemCount, after = after, filter = filterInput, - orderBy = orderByInput + orderBy = orderByInput, + forceNetwork = forceNetwork ) } } + + suspend fun getCourses(forceNetwork: Boolean = false): List { + return horizonGetCoursesManager.getCoursesWithProgress( + userId = apiPrefs.user?.id ?: 0L, + forceNetwork = forceNetwork + ).dataOrNull.orEmpty() + } + + suspend fun deleteNote(noteId: String) { + redwoodApiManager.deleteNote(noteId) + } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookScreen.kt index fdf3723dae..4e03a3aafa 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookScreen.kt @@ -19,194 +19,293 @@ package com.instructure.horizon.features.notebook import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Scaffold +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.navigation.NavHostController -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteObjectType +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R +import com.instructure.horizon.features.notebook.common.composable.NoteDeleteConfirmationDialog import com.instructure.horizon.features.notebook.common.composable.NotebookAppBar import com.instructure.horizon.features.notebook.common.composable.NotebookHighlightedText -import com.instructure.horizon.features.notebook.common.composable.NotebookPill import com.instructure.horizon.features.notebook.common.composable.NotebookTypeSelect +import com.instructure.horizon.features.notebook.common.composable.toNotebookLocalisedDateFormat import com.instructure.horizon.features.notebook.common.model.Note -import com.instructure.horizon.features.notebook.common.model.NotebookType +import com.instructure.horizon.features.notebook.navigation.NotebookRoute import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius -import com.instructure.horizon.horizonui.foundation.HorizonElevation import com.instructure.horizon.horizonui.foundation.HorizonSpace import com.instructure.horizon.horizonui.foundation.HorizonTypography import com.instructure.horizon.horizonui.foundation.SpaceSize -import com.instructure.horizon.horizonui.foundation.horizonShadow -import com.instructure.horizon.horizonui.molecules.IconButton +import com.instructure.horizon.horizonui.foundation.horizonBorder +import com.instructure.horizon.horizonui.foundation.horizonBorderShadow +import com.instructure.horizon.horizonui.molecules.Button +import com.instructure.horizon.horizonui.molecules.ButtonColor +import com.instructure.horizon.horizonui.molecules.ButtonHeight +import com.instructure.horizon.horizonui.molecules.ButtonWidth +import com.instructure.horizon.horizonui.molecules.DropdownChip +import com.instructure.horizon.horizonui.molecules.DropdownItem import com.instructure.horizon.horizonui.molecules.IconButtonColor import com.instructure.horizon.horizonui.molecules.IconButtonSize +import com.instructure.horizon.horizonui.molecules.LoadingIconButton import com.instructure.horizon.horizonui.molecules.Spinner +import com.instructure.horizon.horizonui.organisms.CollapsableHeaderScreen +import com.instructure.horizon.horizonui.platform.LoadingStateWrapper import com.instructure.horizon.navigation.MainNavigationRoute import com.instructure.pandautils.compose.modifiers.conditional +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.getActivityOrNull import com.instructure.pandautils.utils.localisedFormat -import java.util.Date @Composable fun NotebookScreen( mainNavController: NavHostController, - state: NotebookUiState, - onDismiss: (() -> Unit)? = null, - onNoteSelected: ((Note) -> Unit)? = null, + viewModel: NotebookViewModel, +) { + val state by viewModel.uiState.collectAsState() + + NotebookScreen( + navController = mainNavController, + state = state + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotebookScreen( + navController: NavHostController, + state: NotebookUiState ) { + val activity = LocalContext.current.getActivityOrNull() + LaunchedEffect(Unit) { + if (activity != null) ViewStyler.setStatusBarColor( + activity, + ContextCompat.getColor(activity, R.color.surface_pagePrimary) + ) + } + val scrollState = rememberLazyListState() - Scaffold( - containerColor = HorizonColors.Surface.pagePrimary(), - topBar = { + + NoteDeleteConfirmationDialog( + showDialog = state.showDeleteConfirmationForNote != null, + dismissDialog = { state.updateShowDeleteConfirmation(null) }, + onDeleteSelected = { + state.deleteNote(state.showDeleteConfirmationForNote) + } + ) + + CollapsableHeaderScreen( + modifier = Modifier.background(HorizonColors.Surface.pagePrimary()), + headerContent = { if (state.showTopBar) { - NotebookAppBar( - navigateBack = { mainNavController.popBackStack() }, - modifier = Modifier.conditional(scrollState.canScrollBackward) { - horizonShadow( - elevation = HorizonElevation.level2, - ) - } - ) - } else if (onDismiss != null) { - NotebookAppBar( - onClose = { onDismiss() }, - modifier = Modifier.conditional(scrollState.canScrollBackward) { - horizonShadow( - elevation = HorizonElevation.level2, - ) - } - ) - } - }, - ) { padding -> - LazyColumn( - state = scrollState, - modifier = Modifier - .padding(padding), - contentPadding = PaddingValues(24.dp) - ) { - if (state.showFilters && state.notes.isNotEmpty()) { - item { - FilterContent( - state.selectedFilter, - state.onFilterSelected + if (state.showCourseFilter) { + NotebookAppBar( + navigateBack = { navController.popBackStack() }, + centeredTitle = true + ) + } else { + NotebookAppBar( + onClose = { navController.popBackStack() }, + centeredTitle = false ) } - } - if (state.notes.isNotEmpty()){ - item { - Column { - Text( - text = stringResource(R.string.notebookNotesLabel), - style = HorizonTypography.labelLargeBold, - color = HorizonColors.Text.title() + } + }, + bodyContent = { + LoadingStateWrapper(state.loadingState) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + ) { + if ((state.showNoteTypeFilter || state.showCourseFilter)) { + FilterContent( + state, + scrollState, + Modifier + .background(HorizonColors.Surface.pagePrimary()) + .clip(HorizonCornerRadius.level5) + .conditional(scrollState.canScrollBackward) { + horizonBorderShadow( + HorizonColors.Surface.inversePrimary(), + bottom = 1.dp, + ) + } + .background(HorizonColors.Surface.pageSecondary()) ) - - HorizonSpace(SpaceSize.SPACE_12) } - } - } - - if (state.isLoading) { - item { - LoadingContent() - } - } else if (state.notes.isEmpty()) { - item { - EmptyContent() - } - } else { - items(state.notes) { note -> - Column { - NoteContent(note) { - onNoteSelected?.invoke(note) ?: mainNavController.navigate( - MainNavigationRoute.ModuleItemSequence( - courseId = note.courseId, - moduleItemAssetType = note.objectType.value, - moduleItemAssetId = note.objectId, - ) - ) - } - if (state.notes.lastOrNull() != note) { - HorizonSpace(SpaceSize.SPACE_12) - } - } - } + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + state = scrollState, + modifier = Modifier + .fillMaxHeight() + .background(HorizonColors.Surface.pageSecondary()), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 2.dp, + bottom = 16.dp + ) + ) { + if (state.notes.isEmpty()) { + item { + if (state.selectedCourse != null || state.selectedFilter != null) { + EmptyFilteredContent() + } else { + EmptyContent() + } + } + } else { + items(state.notes) { note -> + Column { + val courseName = if (state.showCourseFilter) { + state.courses.firstOrNull { it.courseId == note.courseId }?.courseName + } else null + NoteContent(note, courseName, state.deleteLoadingNote, onDeleteClick = { + state.updateShowDeleteConfirmation(note) + }) { + if (state.navigateToEdit) { + navController.navigate( + NotebookRoute.EditNotebook( + noteId = note.id, + highlightedTextStartOffset = note.highlightedText.range.startOffset, + highlightedTextEndOffset = note.highlightedText.range.endOffset, + highlightedTextStartContainer = note.highlightedText.range.startContainer, + highlightedTextEndContainer = note.highlightedText.range.endContainer, + textSelectionStart = note.highlightedText.textPosition.start, + textSelectionEnd = note.highlightedText.textPosition.end, + highlightedText = note.highlightedText.selectedText, + noteType = note.type.name, + userComment = note.userText, + updatedAt = note.updatedAt.toNotebookLocalisedDateFormat() + ) + ) + } else { + navController.navigate( + MainNavigationRoute.ModuleItemSequence( + courseId = note.courseId, + moduleItemAssetType = note.objectType.value, + moduleItemAssetId = note.objectId, + ) + ) + } + } - item { - Column { - HorizonSpace(SpaceSize.SPACE_24) + if (state.notes.lastOrNull() != note) { + HorizonSpace(SpaceSize.SPACE_4) + } + } + } - NotesPager( - canNavigateBack = state.hasPreviousPage, - canNavigateForward = state.hasNextPage, - isLoading = state.isLoading, - onNavigateBack = state.loadPreviousPage, - onNavigateForward = state.loadNextPage - ) + if (state.hasNextPage) { + item { + if (state.isLoadingMore) { + LoadingContent() + } else { + Button( + label = stringResource(R.string.showMore), + height = ButtonHeight.SMALL, + width = ButtonWidth.FILL, + color = ButtonColor.WhiteWithOutline, + onClick = { state.loadNextPage() }, + ) + } + } + } + } } } } } - } + ) } @Composable private fun FilterContent( - selectedFilter: NotebookType?, - onFilterSelected: (NotebookType?) -> Unit, + state: NotebookUiState, + scrollState: LazyListState, + modifier: Modifier = Modifier, ) { - Column { - Text( - text = stringResource(R.string.notebookFilterLabel), - style = HorizonTypography.labelLargeBold, - color = HorizonColors.Text.title() - ) + val context = LocalContext.current + val defaultBackgroundColor = HorizonColors.PrimitivesGrey.grey12() - HorizonSpace(SpaceSize.SPACE_12) + // Course filter items + val allCoursesItem = DropdownItem( + value = null, + label = context.getString(R.string.notebookFilterCoursePlaceholder), + iconRes = null, + iconTint = null, + backgroundColor = defaultBackgroundColor + ) - Row { - NotebookTypeSelect( - type = NotebookType.Important, - isSelected = selectedFilter == NotebookType.Important, - onSelect = { onFilterSelected(if (selectedFilter == NotebookType.Important) null else NotebookType.Important) }, - modifier = Modifier.weight(1f) + val courseItems = remember(state.courses) { + listOf(allCoursesItem) + state.courses.map { course -> + DropdownItem( + value = course, + label = course.courseName, + iconRes = null, + iconTint = null, + backgroundColor = defaultBackgroundColor ) + } + } - HorizonSpace(SpaceSize.SPACE_12) + val selectedCourseItem = + if (state.selectedCourse == null) allCoursesItem else courseItems.find { it.value == state.selectedCourse } - NotebookTypeSelect( - type = NotebookType.Confusing, - isSelected = selectedFilter == NotebookType.Confusing, - onSelect = { onFilterSelected(if (selectedFilter == NotebookType.Confusing) null else NotebookType.Confusing) }, - modifier = Modifier.weight(1f) - ) - } - HorizonSpace(SpaceSize.SPACE_24) - } + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (state.showCourseFilter) { + DropdownChip( + items = courseItems, + selectedItem = selectedCourseItem, + onItemSelected = { item -> state.onCourseSelected(item?.value) }, + placeholder = stringResource(R.string.notebookFilterCoursePlaceholder), + dropdownWidth = 178.dp, + verticalPadding = 6.dp, + modifier = Modifier.weight(1f, false) + ) + } + + if (state.showNoteTypeFilter) { + NotebookTypeSelect(state.selectedFilter, state.onFilterSelected, false, true) + } + } } @Composable @@ -225,20 +324,27 @@ private fun LoadingContent() { @Composable private fun NoteContent( note: Note, + courseName: String?, + deleteLoading: Note?, + onDeleteClick: () -> Unit, onClick: () -> Unit, ) { Column( modifier = Modifier .fillMaxWidth() - .horizonShadow( - elevation = HorizonElevation.level4, - shape = HorizonCornerRadius.level2, - clip = true + .horizonBorder( + colorResource(note.type.color).copy(alpha = 0.1f), + 6.dp, + 1.dp, + 1.dp, + 6.dp, + 16.dp ) .background( color = HorizonColors.PrimitivesWhite.white10(), shape = HorizonCornerRadius.level2, ) + .clip(HorizonCornerRadius.level2) .clickable { onClick() } ) { Column( @@ -246,10 +352,17 @@ private fun NoteContent( .fillMaxWidth() .padding(24.dp) ) { - Text( - text = note.updatedAt.localisedFormat("MMM d, yyyy"), - style = HorizonTypography.labelSmall, - color = HorizonColors.Text.timestamp() + val typeName = stringResource(note.type.labelRes) + NotebookTypeSelect( + note.type, + verticalPadding = 2.dp, + onSelect = {}, + showIcons = true, + enabled = false, + showAllOption = false, + modifier = Modifier.clearAndSetSemantics { + contentDescription = typeName + } ) HorizonSpace(SpaceSize.SPACE_16) @@ -271,109 +384,125 @@ private fun NoteContent( overflow = TextOverflow.Ellipsis, ) - HorizonSpace(SpaceSize.SPACE_16) + HorizonSpace(SpaceSize.SPACE_8) } - NotebookPill(note.type) + Row { + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ){ + Text( + text = note.updatedAt.localisedFormat("MMM d, yyyy"), + style = HorizonTypography.labelMediumBold, + color = HorizonColors.Text.timestamp(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (courseName != null) { + Text( + text = courseName, + style = HorizonTypography.labelMediumBold, + color = HorizonColors.Text.timestamp(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + LoadingIconButton( + iconRes = R.drawable.delete, + contentDescription = stringResource(R.string.a11y_notebookDeleteNoteButtonContentDescription), + color = IconButtonColor.InverseDanger, + size = IconButtonSize.SMALL, + onClick = { onDeleteClick() }, + loading = note == deleteLoading, + modifier = Modifier.align(Alignment.Bottom) + ) + } } } } @Composable private fun EmptyContent(modifier: Modifier = Modifier) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .fillMaxWidth() - .horizonShadow( - elevation = HorizonElevation.level4, - shape = HorizonCornerRadius.level2, - clip = true - ) - .background( - color = HorizonColors.PrimitivesWhite.white10(), - shape = HorizonCornerRadius.level2, - ) + Column( + horizontalAlignment = Alignment.Start, + modifier = modifier.fillMaxWidth() ) { Text( - text = stringResource(R.string.notebookEmptyContentMessage), + text = stringResource(R.string.notesEmptyContentTitle), + style = HorizonTypography.sh2, + color = HorizonColors.Text.body() + ) + HorizonSpace(size = SpaceSize.SPACE_8) + Text( + text = stringResource(R.string.notesEmptyContentBody), style = HorizonTypography.p1, - color = HorizonColors.Text.body(), - modifier = Modifier.padding(vertical = 40.dp, horizontal = 36.dp) + color = HorizonColors.Text.dataPoint() ) } } @Composable -private fun NotesPager( - canNavigateBack: Boolean, - canNavigateForward: Boolean, - isLoading: Boolean, - onNavigateBack: () -> Unit, - onNavigateForward: () -> Unit, -) { - if (canNavigateBack || canNavigateForward) { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - IconButton( - iconRes = R.drawable.chevron_left, - color = IconButtonColor.Black, - size = IconButtonSize.SMALL, - onClick = onNavigateBack, - enabled = canNavigateBack && !isLoading - ) - - HorizonSpace(SpaceSize.SPACE_8) +private fun EmptyFilteredContent(modifier: Modifier = Modifier) { + Column( + horizontalAlignment = Alignment.Start, + modifier = modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.notesEmptyFilteredContentTitle), + style = HorizonTypography.sh2, + color = HorizonColors.Text.body() + ) + HorizonSpace(size = SpaceSize.SPACE_8) + Text( + text = stringResource(R.string.notesEmptyFilteredContentBody), + style = HorizonTypography.p1, + color = HorizonColors.Text.dataPoint() + ) + } +} - IconButton( - iconRes = R.drawable.chevron_right, - color = IconButtonColor.Black, - size = IconButtonSize.SMALL, - onClick = onNavigateForward, - enabled = canNavigateForward && !isLoading - ) - } +@Composable +private fun ErrorContent(modifier: Modifier = Modifier) { + Column( + horizontalAlignment = Alignment.Start, + modifier = modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.notesErrorContentTitle), + style = HorizonTypography.sh2, + color = HorizonColors.Text.body() + ) + HorizonSpace(size = SpaceSize.SPACE_8) + Text( + text = stringResource(R.string.notesErrorContentBody), + style = HorizonTypography.p1, + color = HorizonColors.Text.dataPoint() + ) } } @Composable @Preview -private fun NotebookScreenPreview() { +private fun NotebookScreenEmptyPreview() { ContextKeeper.appContext = LocalContext.current - val state = NotebookUiState( - isLoading = false, - showFilters = true, - showTopBar = true, - selectedFilter = NotebookType.Important, - notes = listOf( - Note( - id = "1", - courseId = 123L, - objectId = "456", - objectType = NoteObjectType.PAGE, - userText = "This is a note about an assignment.", - highlightedText = NoteHighlightedData("Important part of the assignment.", NoteHighlightedDataRange(0, 0, "", ""), NoteHighlightedDataTextPosition(0, 0)), - updatedAt = Date(), - type = NotebookType.Important - ), - Note( - id = "2", - courseId = 123L, - objectId = "789", - objectType = NoteObjectType.PAGE, - userText = "This is a note about another assignment.", - highlightedText = NoteHighlightedData("Confusing part of the assignment.", NoteHighlightedDataRange(0, 0, "", ""), NoteHighlightedDataTextPosition(0, 0)), - updatedAt = Date(), - type = NotebookType.Confusing - ) - ), - updateContent = { _, _ -> } - ) + EmptyContent() +} - NotebookScreen( - mainNavController = NavHostController(LocalContext.current), - state = state - ) -} \ No newline at end of file +@Composable +@Preview +private fun NotebookScreenEmptyFilteredPreview() { + ContextKeeper.appContext = LocalContext.current + EmptyFilteredContent() +} + +@Composable +@Preview +private fun NotebookScreenErrorPreview() { + ContextKeeper.appContext = LocalContext.current + ErrorContent() +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookUiState.kt index 2fb4184155..901b289d91 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookUiState.kt @@ -16,19 +16,29 @@ */ package com.instructure.horizon.features.notebook +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress import com.instructure.horizon.features.notebook.common.model.Note import com.instructure.horizon.features.notebook.common.model.NotebookType +import com.instructure.horizon.horizonui.platform.LoadingState data class NotebookUiState( - val isLoading: Boolean = true, + val loadingState: LoadingState = LoadingState(), + val isLoadingMore: Boolean = false, val selectedFilter: NotebookType? = null, val onFilterSelected: (NotebookType?) -> Unit = {}, + val selectedCourse: CourseWithProgress? = null, + val onCourseSelected: (CourseWithProgress?) -> Unit = {}, + val courses: List = emptyList(), val notes: List = emptyList(), - val hasPreviousPage: Boolean = false, val hasNextPage: Boolean = false, - val loadPreviousPage: () -> Unit = {}, val loadNextPage: () -> Unit = {}, - val updateContent: (Long?, Pair?) -> Unit, val showTopBar: Boolean = false, val showFilters: Boolean = false, + val navigateToEdit: Boolean = false, + val showNoteTypeFilter: Boolean = true, + val showCourseFilter: Boolean = true, + val showDeleteConfirmationForNote: Note? = null, + val updateShowDeleteConfirmation: (Note?) -> Unit = {}, + val deleteNote: (Note?) -> Unit = {}, + val deleteLoadingNote: Note? = null ) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookViewModel.kt index 262e6aacd5..f36d491956 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/NotebookViewModel.kt @@ -16,15 +16,24 @@ */ package com.instructure.horizon.features.notebook +import android.content.Context +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.R +import com.instructure.horizon.features.notebook.common.model.Note import com.instructure.horizon.features.notebook.common.model.NotebookType import com.instructure.horizon.features.notebook.common.model.mapToNotes +import com.instructure.horizon.features.notebook.navigation.NotebookRoute +import com.instructure.horizon.horizonui.platform.LoadingState import com.instructure.redwood.QueryNotesQuery import com.instructure.redwood.type.OrderDirection import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -32,112 +41,177 @@ import javax.inject.Inject @HiltViewModel class NotebookViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val repository: NotebookRepository, -): ViewModel() { - private var cursorId: String? = null + savedStateHandle: SavedStateHandle, +) : ViewModel() { private var pageInfo: QueryNotesQuery.PageInfo? = null + private var loadJob: Job? = null - private var courseId: Long? = null - private var objectTypeAndId: Pair? = null + private var courseId: Long? = + savedStateHandle.get(NotebookRoute.Notebook.COURSE_ID)?.toLongOrNull() + private var objectTypeAndId: Pair? = getObjectTypeAndId(savedStateHandle) + private var showTopBar: Boolean = + savedStateHandle.get(NotebookRoute.Notebook.SHOW_TOP_BAR) ?: false + private var showFilters: Boolean = + savedStateHandle.get(NotebookRoute.Notebook.SHOW_FILTERS) ?: false + private var navigateToEdit: Boolean = + savedStateHandle.get(NotebookRoute.Notebook.NAVIGATE_TO_EDIT) ?: false - private val _uiState = MutableStateFlow(NotebookUiState( - loadPreviousPage = ::getPreviousPage, - loadNextPage = ::getNextPage, - onFilterSelected = ::onFilterSelected, - updateContent = ::updateContent - )) + private val _uiState = MutableStateFlow( + NotebookUiState( + loadingState = LoadingState( + onSnackbarDismiss = ::onSnackbarDismiss, + onRefresh = ::refresh + ), + loadNextPage = ::getNextPage, + onFilterSelected = ::onFilterSelected, + onCourseSelected = ::onCourseSelected, + showTopBar = showTopBar, + showFilters = showFilters, + showCourseFilter = courseId == null, + navigateToEdit = navigateToEdit, + updateShowDeleteConfirmation = ::updateShowDeleteConfirmation, + deleteNote = ::deleteNote + ) + ) val uiState = _uiState.asStateFlow() init { loadData() - updateScreenState() } - private fun loadData( - after: String? = null, - before: String? = null, - courseId: Long? = this.courseId, - ) { - viewModelScope.tryLaunch { - _uiState.update { - it.copy(isLoading = true) - } - - val notesResponse = repository.getNotes( - after = after, - before = before, - filterType = uiState.value.selectedFilter, - courseId = courseId, - objectTypeAndId = objectTypeAndId, - orderDirection = OrderDirection.descending - ) - cursorId = notesResponse.edges?.firstOrNull()?.cursor - pageInfo = notesResponse.pageInfo - - val notes = notesResponse.mapToNotes() - - _uiState.update { - it.copy( + fun loadData() { + loadJob?.cancel() + loadJob = viewModelScope.tryLaunch { + _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true, isRefreshing = false, isError = false, errorMessage = null)) } + fetchData() + _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false, isError = false, errorMessage = null)) } + } catch { + _uiState.update { it.copy( + loadingState = it.loadingState.copy( isLoading = false, - notes = notes, - hasPreviousPage = notesResponse.pageInfo.hasPreviousPage, - hasNextPage = notesResponse.pageInfo.hasNextPage, + isError = true, + errorMessage = context.getString( + R.string.notebookFailedToLoadErrorMessage + ) ) - } + ) } + } + } + + fun refresh() { + pageInfo = null + loadJob?.cancel() + loadJob = viewModelScope.tryLaunch { + _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = true)) } + fetchData(forceNetwork = true) + _uiState.update { it.copy(loadingState = it.loadingState.copy(isRefreshing = false, isError = false, errorMessage = null)) } } catch { - _uiState.update { - it.copy( - isLoading = false, - notes = emptyList(), - hasPreviousPage = false, - hasNextPage = false, + _uiState.update { it.copy( + loadingState = it.loadingState.copy( + isRefreshing = false, + snackbarMessage = context.getString( + R.string.notebookFailedToLoadErrorMessage + ) ) - } + ) } } } - private fun getNextPage() { - loadData(after = pageInfo?.endCursor) + private suspend fun fetchData(forceNetwork: Boolean = false) { + fetchCourses(forceNetwork) + val notes = fetchNotes(forceNetwork) + _uiState.update { it.copy(notes = notes) } } - private fun getPreviousPage() { - loadData(before = pageInfo?.startCursor) + private suspend fun fetchCourses(forceNetwork: Boolean = false) { + val courses = repository.getCourses(forceNetwork) + _uiState.update { it.copy(courses = courses) } } - private fun onFilterSelected(newFilter: NotebookType?) { - _uiState.update { currentState -> - currentState.copy(selectedFilter = newFilter) + private suspend fun fetchNotes(forceNetwork: Boolean = false): List { + val notesResponse = repository.getNotes( + after = pageInfo?.endCursor, + before = null, + filterType = uiState.value.selectedFilter, + courseId = courseId, + objectTypeAndId = objectTypeAndId, + orderDirection = OrderDirection.descending, + forceNetwork = forceNetwork + ) + pageInfo = notesResponse.pageInfo + + _uiState.update { + it.copy(hasNextPage = notesResponse.pageInfo.hasNextPage) } - loadData() + + return notesResponse.mapToNotes() } - private fun updateContent(courseId: Long?, objectTypeAndId: Pair?) { - if (courseId != this.courseId || objectTypeAndId != this.objectTypeAndId) { - this.courseId = courseId - this.objectTypeAndId = objectTypeAndId - loadData() + private fun getNextPage() { + loadJob?.cancel() + loadJob = viewModelScope.tryLaunch { + _uiState.update { it.copy(isLoadingMore = true) } + val notes = fetchNotes() + _uiState.update { it.copy(notes = it.notes + notes, isLoadingMore = false) } + } catch { + _uiState.update { it.copy(loadingState = it.loadingState.copy(snackbarMessage = "Failed to load notes")) } } - updateScreenState() } - fun updateCourseId(courseId: Long?) { - if (courseId != this.courseId) { - this.courseId = courseId - loadData() - } - updateScreenState() + private fun onFilterSelected(newFilter: NotebookType?) { + pageInfo = null + _uiState.update { it.copy(selectedFilter = newFilter) } + loadData() + } + + private fun onCourseSelected(course: CourseWithProgress?) { + pageInfo = null + _uiState.update { it.copy(selectedCourse = course) } + courseId = course?.courseId + loadData() + } + + fun updateFilters(courseId: Long? = null, objectTypeAndId: Pair? = null) { + pageInfo = null + this.courseId = courseId + this.objectTypeAndId = objectTypeAndId + loadData() + } + + private fun onSnackbarDismiss() { + _uiState.update { it.copy(loadingState = it.loadingState.copy(snackbarMessage = null)) } + } + + fun updateScreenState( + showNoteTypeFilter: Boolean = true, + showCourseFilter: Boolean = true, + showTopBar: Boolean = false + ) { + _uiState.update { it.copy(showTopBar = showTopBar, showCourseFilter = showCourseFilter, showNoteTypeFilter = showNoteTypeFilter) } + } + + private fun getObjectTypeAndId(savedStateHandle: SavedStateHandle): Pair? { + val objectType = + savedStateHandle.get(NotebookRoute.Notebook.OBJECT_TYPE) ?: return null + val objectId = savedStateHandle.get(NotebookRoute.Notebook.OBJECT_ID) ?: return null + return Pair(objectType, objectId) + } + + private fun updateShowDeleteConfirmation(note: Note?) { + _uiState.update { it.copy(showDeleteConfirmationForNote = note) } } - private fun updateScreenState() { - if (courseId != null) { - _uiState.update { it.copy(showTopBar = false) } - if (objectTypeAndId != null) { - _uiState.update { it.copy(showFilters = false) } - } else { - _uiState.update { it.copy(showFilters = true) } + private fun deleteNote(note: Note?) { + if (note != null) { + viewModelScope.tryLaunch { + _uiState.update { it.copy(deleteLoadingNote = note) } + repository.deleteNote(note.id) + _uiState.update { it.copy(deleteLoadingNote = null, notes = it.notes.filterNot { it == note }) } + } catch { + _uiState.update { it.copy(deleteLoadingNote = null) } } - } else { - _uiState.update { it.copy(showTopBar = true, showFilters = true) } } } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteScreen.kt index 4efc360ecf..e2563e208e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteScreen.kt @@ -16,16 +16,21 @@ */ package com.instructure.horizon.features.notebook.addedit +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -33,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -42,7 +48,7 @@ import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlight import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R -import com.instructure.horizon.features.notebook.common.composable.NotebookAppBar +import com.instructure.horizon.features.notebook.common.composable.NoteDeleteConfirmationDialog import com.instructure.horizon.features.notebook.common.composable.NotebookHighlightedText import com.instructure.horizon.features.notebook.common.composable.NotebookTypeSelect import com.instructure.horizon.features.notebook.common.model.NotebookType @@ -56,6 +62,8 @@ import com.instructure.horizon.horizonui.molecules.ButtonHeight import com.instructure.horizon.horizonui.molecules.ButtonIconPosition import com.instructure.horizon.horizonui.molecules.ButtonWidth import com.instructure.horizon.horizonui.molecules.Spinner +import com.instructure.horizon.horizonui.organisms.Modal +import com.instructure.horizon.horizonui.organisms.ModalDialogState import com.instructure.horizon.horizonui.organisms.inputs.common.InputLabelRequired import com.instructure.horizon.horizonui.organisms.inputs.textarea.TextArea import com.instructure.horizon.horizonui.organisms.inputs.textarea.TextAreaState @@ -70,45 +78,105 @@ fun AddEditNoteScreen( ) { val activity = LocalContext.current.getActivityOrNull() LaunchedEffect(Unit) { - if (activity != null) ViewStyler.setStatusBarColor(activity, ContextCompat.getColor(activity, R.color.surface_pagePrimary)) + if (activity != null) ViewStyler.setStatusBarColor(activity, ContextCompat.getColor(activity, R.color.surface_pageSecondary)) } LaunchedEffect(state.snackbarMessage) { onShowSnackbar(state.snackbarMessage, state.onSnackbarDismiss) } + BackHandler { + if (state.hasContentChange) { + state.updateExitConfirmationDialog(true) + } else { + navController.popBackStack() + } + } + Scaffold( - containerColor = HorizonColors.Surface.pagePrimary(), - topBar = { NotebookAppBar(navigateBack = { navController.popBackStack() }) }, + containerColor = HorizonColors.Surface.pageSecondary(), + topBar = { AddEditNoteAppBar(state, navigateBack = { navController.popBackStack() }) }, ) { padding -> if (state.isLoading) { AddEditNoteLoading(padding) } else { - AddEditNoteContent( - state = state, - navController = navController, - padding = padding + NoteDeleteConfirmationDialog( + showDialog = state.showDeleteConfirmationDialog, + onDeleteSelected = { + state.onDeleteNote?.invoke { + navController.popBackStack() + } + }, + dismissDialog = { state.updateDeleteConfirmationDialog(false) } ) + AddEditNoteScreenExitConfirmationDialog(state, navController) + AddEditNoteContent(state, padding) } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddEditNoteAppBar( + state: AddEditNoteUiState, + navigateBack: () -> Unit +) { + CenterAlignedTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = HorizonColors.Surface.pageSecondary(), + titleContentColor = HorizonColors.Text.title(), + navigationIconContentColor = HorizonColors.Icon.default() + ), + title = { + Text( + state.title, + style = HorizonTypography.h4, + color = HorizonColors.Text.title() + ) + }, + navigationIcon = { + Button( + label = stringResource(R.string.editNoteCancelButtonLabel), + onClick = { + if (state.hasContentChange) { + state.updateExitConfirmationDialog(true) + } else { + navigateBack() + } + }, + color = ButtonColor.WhiteWithOutline, + height = ButtonHeight.SMALL, + enabled = !state.isLoading + ) + }, + actions = { + Button( + label = stringResource(R.string.editNoteSaveButtonLabel), + onClick = { state.onSaveNote(navigateBack) }, + color = ButtonColor.Black, + height = ButtonHeight.SMALL, + enabled = state.hasContentChange && !state.isLoading + ) + }, + modifier = Modifier.padding(horizontal = 16.dp) + ) +} + + @Composable -private fun AddEditNoteContent(state: AddEditNoteUiState, navController: NavHostController, padding: PaddingValues) { +private fun AddEditNoteContent(state: AddEditNoteUiState, padding: PaddingValues) { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(padding) - .padding(24.dp) + .padding(horizontal = 16.dp) ) { - Text( - text = stringResource(R.string.addNoteHighlightlabel), - style = HorizonTypography.labelLargeBold, - color = HorizonColors.Text.body() - ) + HorizonSpace(SpaceSize.SPACE_24) - HorizonSpace(SpaceSize.SPACE_8) + NotebookTypeSelect(state.type, state.onTypeChanged, true, false) + + HorizonSpace(SpaceSize.SPACE_24) NotebookHighlightedText( text = state.highlightedData.selectedText, @@ -117,65 +185,46 @@ private fun AddEditNoteContent(state: AddEditNoteUiState, navController: NavHost HorizonSpace(SpaceSize.SPACE_24) - Text( - text = stringResource(R.string.addNoteLabelLabel), - style = HorizonTypography.labelLargeBold, - color = HorizonColors.Text.body() - ) - - HorizonSpace(SpaceSize.SPACE_8) - - Row { - NotebookTypeSelect( - type = NotebookType.Important, - isSelected = state.type == NotebookType.Important, - onSelect = { state.onTypeChanged(if (state.type == NotebookType.Important) null else NotebookType.Important) }, - modifier = Modifier.weight(1f) - ) - - HorizonSpace(SpaceSize.SPACE_12) - - NotebookTypeSelect( - type = NotebookType.Confusing, - isSelected = state.type == NotebookType.Confusing, - onSelect = { state.onTypeChanged(if (state.type == NotebookType.Confusing) null else NotebookType.Confusing) }, - modifier = Modifier.weight(1f) - ) - } - - HorizonSpace(SpaceSize.SPACE_24) - TextArea( state = TextAreaState( - label = stringResource(R.string.addNoteAddANoteLabel), + placeHolderText = stringResource(R.string.addNoteAddANoteLabel), required = InputLabelRequired.Optional, value = state.userComment, onValueChange = state.onUserCommentChanged, ), - minLines = 5 + minLines = 5, + maxLines = 5 ) HorizonSpace(SpaceSize.SPACE_16) - Button( - label = stringResource(R.string.addNoteSaveLabel), - onClick = { state.onSaveNote { navController.popBackStack() } }, - enabled = !state.isLoading && state.type != null, - color = ButtonColor.Institution, - width = ButtonWidth.FILL, - height = ButtonHeight.NORMAL - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + if (state.lastModifiedDate != null) { + Text( + state.lastModifiedDate, + style = HorizonTypography.labelSmall, + color = HorizonColors.Text.timestamp(), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } else { + Spacer(Modifier.weight(1f)) + } - if (state.onDeleteNote != null) { - HorizonSpace(SpaceSize.SPACE_16) - Button( - label = stringResource(R.string.addNoteDeleteLabel), - onClick = { state.onDeleteNote?.invoke { navController.popBackStack() } }, - color = ButtonColor.DangerInverse, - width = ButtonWidth.FILL, - height = ButtonHeight.NORMAL, - iconPosition = ButtonIconPosition.Start(R.drawable.delete), - ) + if (state.onDeleteNote != null) { + Button( + label = stringResource(R.string.deleteNoteLabel), + width = ButtonWidth.RELATIVE, + height = ButtonHeight.SMALL, + color = ButtonColor.DangerInverse, + iconPosition = ButtonIconPosition.Start(R.drawable.delete), + onClick = { state.updateDeleteConfirmationDialog(true) } + ) + } } } } @@ -193,11 +242,35 @@ private fun AddEditNoteLoading(padding: PaddingValues) { } } +@Composable +private fun AddEditNoteScreenExitConfirmationDialog( + state: AddEditNoteUiState, + navController: NavHostController +) { + if (state.showExitConfirmationDialog) { + Modal( + ModalDialogState( + title = stringResource(R.string.editNoteExitConfirmationTitle), + message = stringResource(R.string.editNoteExitConfirmationMessage), + primaryButtonTitle = stringResource(R.string.editNoteExitConfirmationExitButtonLabel), + primaryButtonClick = { + state.updateExitConfirmationDialog(false) + navController.popBackStack() + }, + secondaryButtonTitle = stringResource(R.string.editNoteExitConfirmationCancelButtonLabel), + secondaryButtonClick = { state.updateExitConfirmationDialog(false) } + ), + onDismiss = { state.updateExitConfirmationDialog(false) } + ) + } +} + @Composable @Preview private fun AddEditNoteScreenPreview() { ContextKeeper.appContext = LocalContext.current val state = AddEditNoteUiState( + title = "Add note", type = NotebookType.Important, highlightedData = NoteHighlightedData( selectedText = "This is a highlighted text", @@ -223,6 +296,7 @@ private fun AddEditNoteScreenPreview() { private fun AddEditNoteScreenLoadingPreview() { ContextKeeper.appContext = LocalContext.current val state = AddEditNoteUiState( + title = "Add note", type = NotebookType.Important, highlightedData = NoteHighlightedData( selectedText = "This is a highlighted text", diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteUiState.kt index b543e57d2e..9c03eb3a13 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditNoteUiState.kt @@ -18,17 +18,30 @@ package com.instructure.horizon.features.notebook.addedit import androidx.compose.ui.text.input.TextFieldValue import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition import com.instructure.horizon.features.notebook.common.model.NotebookType data class AddEditNoteUiState( - val highlightedData: NoteHighlightedData, + val title: String = "", + val highlightedData: NoteHighlightedData = NoteHighlightedData( + "", + NoteHighlightedDataRange(0, 0, "", ""), + NoteHighlightedDataTextPosition(0, 0) + ), val userComment: TextFieldValue = TextFieldValue(""), - val onUserCommentChanged: (TextFieldValue) -> Unit, + val onUserCommentChanged: (TextFieldValue) -> Unit = {}, val type: NotebookType? = null, val onTypeChanged: (NotebookType?) -> Unit, - val onSaveNote: (() -> Unit) -> Unit, + val onSaveNote: (() -> Unit) -> Unit = {}, val isLoading: Boolean = false, + val lastModifiedDate: String? = null, + val showDeleteConfirmationDialog: Boolean = false, + val updateDeleteConfirmationDialog: (Boolean) -> Unit = {}, + val showExitConfirmationDialog: Boolean = false, + val updateExitConfirmationDialog: (Boolean) -> Unit = {}, val onDeleteNote: ((() -> Unit) -> Unit)? = null, val snackbarMessage: String? = null, val onSnackbarDismiss: () -> Unit, + val hasContentChange: Boolean = false ) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditViewModel.kt new file mode 100644 index 0000000000..fa839ce8ad --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/AddEditViewModel.kt @@ -0,0 +1,61 @@ +/* + * 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.notebook.addedit + +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.instructure.horizon.features.notebook.common.model.NotebookType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +abstract class AddEditViewModel: ViewModel() { + protected val _uiState = MutableStateFlow( + AddEditNoteUiState( + onTypeChanged = ::onTypeChanged, + onUserCommentChanged = ::onUserCommentChanged, + onSnackbarDismiss = ::onSnackbarDismissed, + updateDeleteConfirmationDialog = ::updateShowDeleteConfirmationDialog, + updateExitConfirmationDialog = ::updateShowExitConfirmationDialog + ) + ) + val uiState = _uiState.asStateFlow() + + private fun onTypeChanged(newType: NotebookType?) { + _uiState.update { it.copy(type = newType,) } + _uiState.update { it.copy(hasContentChange = hasContentChange()) } + } + + private fun onUserCommentChanged(userComment: TextFieldValue) { + _uiState.update { it.copy(userComment = userComment) } + _uiState.update { it.copy(hasContentChange = hasContentChange()) } + } + + private fun onSnackbarDismissed() { + _uiState.update { it.copy(snackbarMessage = null) } + } + + private fun updateShowExitConfirmationDialog(value: Boolean) { + _uiState.update { it.copy(showExitConfirmationDialog = value) } + } + + private fun updateShowDeleteConfirmationDialog(value: Boolean) { + _uiState.update { it.copy(showDeleteConfirmationDialog = value) } + } + + protected abstract fun hasContentChange(): Boolean +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/add/AddNoteViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/add/AddNoteViewModel.kt index 264b1e2c17..b3f20b07ac 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/add/AddNoteViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/add/AddNoteViewModel.kt @@ -17,9 +17,7 @@ package com.instructure.horizon.features.notebook.addedit.add import android.content.Context -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData @@ -28,13 +26,11 @@ import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlight import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R -import com.instructure.horizon.features.notebook.addedit.AddEditNoteUiState +import com.instructure.horizon.features.notebook.addedit.AddEditViewModel import com.instructure.horizon.features.notebook.common.model.NotebookType import com.instructure.horizon.features.notebook.navigation.NotebookRoute import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -43,7 +39,7 @@ class AddNoteViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: AddNoteRepository, savedStateHandle: SavedStateHandle -): ViewModel() { +): AddEditViewModel() { private val courseId: String = savedStateHandle.toRoute().courseId private val objectType: String = savedStateHandle.toRoute().objectType private val objectId: String = savedStateHandle.toRoute().objectId @@ -56,29 +52,30 @@ class AddNoteViewModel @Inject constructor( private val highlightedText: String = savedStateHandle.toRoute().highlightedText private val noteType: String? = savedStateHandle.toRoute().noteType - private val _uiState = MutableStateFlow( - AddEditNoteUiState( - highlightedData = NoteHighlightedData( - selectedText = highlightedText, - range = NoteHighlightedDataRange( - startOffset = highlightedTextStartOffset, - endOffset = highlightedTextEndOffset, - startContainer = highlightedTextStartContainer, - endContainer = highlightedTextEndContainer + init { + _uiState.update { + it.copy( + title = context.getString(R.string.createNoteTitle), + hasContentChange = true, + highlightedData = NoteHighlightedData( + selectedText = highlightedText, + range = NoteHighlightedDataRange( + startOffset = highlightedTextStartOffset, + endOffset = highlightedTextEndOffset, + startContainer = highlightedTextStartContainer, + endContainer = highlightedTextEndContainer + ), + textPosition = NoteHighlightedDataTextPosition( + start = highlightedTextSelectionStart, + end = highlightedTextSelectionEnd + ) ), - textPosition = NoteHighlightedDataTextPosition( - start = highlightedTextSelectionStart, - end = highlightedTextSelectionEnd - ) - ), - onTypeChanged = ::onTypeChanged, - onUserCommentChanged = ::onUserCommentChanged, - onSaveNote = ::addNote, - onSnackbarDismiss = ::onSnackbarDismissed, - type = if (noteType == null) null else NotebookType.valueOf(noteType), - ) - ) - val uiState = _uiState.asStateFlow() + onSaveNote = ::addNote, + onDeleteNote = null, + type = if (noteType == null) null else NotebookType.valueOf(noteType), + ) + } + } private fun addNote(onFinished: () -> Unit) { viewModelScope.tryLaunch { @@ -100,19 +97,7 @@ class AddNoteViewModel @Inject constructor( } } - private fun onTypeChanged(newType: NotebookType?) { - _uiState.update { - it.copy( - type = newType - ) - } - } - - private fun onUserCommentChanged(userComment: TextFieldValue) { - _uiState.update { it.copy(userComment = userComment) } - } - - private fun onSnackbarDismissed() { - _uiState.update { it.copy(snackbarMessage = null) } + override fun hasContentChange(): Boolean { + return true } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/edit/EditNoteViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/edit/EditNoteViewModel.kt index 7173d62db6..17f152d1d5 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/edit/EditNoteViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/addedit/edit/EditNoteViewModel.kt @@ -19,7 +19,6 @@ package com.instructure.horizon.features.notebook.addedit.edit import android.content.Context import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData @@ -28,13 +27,11 @@ import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlight import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R -import com.instructure.horizon.features.notebook.addedit.AddEditNoteUiState +import com.instructure.horizon.features.notebook.addedit.AddEditViewModel import com.instructure.horizon.features.notebook.common.model.NotebookType import com.instructure.horizon.features.notebook.navigation.NotebookRoute import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -43,7 +40,7 @@ class EditNoteViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: EditNoteRepository, savedStateHandle: SavedStateHandle -): ViewModel() { +): AddEditViewModel() { private val noteId: String = savedStateHandle.toRoute().noteId private val noteType: String = savedStateHandle.toRoute().noteType @@ -63,32 +60,35 @@ class EditNoteViewModel @Inject constructor( savedStateHandle.toRoute().textSelectionStart private val highlightedTextSelectionEnd: Int = savedStateHandle.toRoute().textSelectionEnd + private val lastModifiedDate: String? = + savedStateHandle.toRoute().updatedAt - private val _uiState = MutableStateFlow( - AddEditNoteUiState( - highlightedData = NoteHighlightedData( - selectedText = highlightedText, - range = NoteHighlightedDataRange( - startOffset = highlightedTextStartOffset, - endOffset = highlightedTextEndOffset, - startContainer = highlightedTextStartContainer, - endContainer = highlightedTextEndContainer + + init { + _uiState.update { + it.copy( + title = context.getString(R.string.editNoteTitle), + highlightedData = NoteHighlightedData( + selectedText = highlightedText, + range = NoteHighlightedDataRange( + startOffset = highlightedTextStartOffset, + endOffset = highlightedTextEndOffset, + startContainer = highlightedTextStartContainer, + endContainer = highlightedTextEndContainer + ), + textPosition = NoteHighlightedDataTextPosition( + start = highlightedTextSelectionStart, + end = highlightedTextSelectionEnd + ) ), - textPosition = NoteHighlightedDataTextPosition( - start = highlightedTextSelectionStart, - end = highlightedTextSelectionEnd - ) - ), - userComment = TextFieldValue(userComment), - type = NotebookType.valueOf(noteType), - onTypeChanged = ::onTypeChanged, - onUserCommentChanged = ::onUserCommentChanged, - onSaveNote = ::editNote, - onDeleteNote = ::deleteNote, - onSnackbarDismiss = ::onSnackbarDismissed, - ) - ) - val uiState = _uiState.asStateFlow() + userComment = TextFieldValue(userComment), + type = NotebookType.valueOf(noteType), + lastModifiedDate = lastModifiedDate, + onSaveNote = ::editNote, + onDeleteNote = ::deleteNote, + ) + } + } private fun editNote(onFinished: () -> Unit) { viewModelScope.tryLaunch { @@ -122,19 +122,8 @@ class EditNoteViewModel @Inject constructor( } } - private fun onTypeChanged(newType: NotebookType?) { - _uiState.update { - it.copy( - type = newType - ) - } - } - - private fun onUserCommentChanged(userComment: TextFieldValue) { - _uiState.update { it.copy(userComment = userComment) } - } - - private fun onSnackbarDismissed() { - _uiState.update { it.copy(snackbarMessage = null) } + override fun hasContentChange(): Boolean { + return uiState.value.userComment.text != userComment || + uiState.value.type != NotebookType.valueOf(noteType) } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/AddEditNoteScreenDeleteConfirmationDialog.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/AddEditNoteScreenDeleteConfirmationDialog.kt new file mode 100644 index 0000000000..97bbccc2a0 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/AddEditNoteScreenDeleteConfirmationDialog.kt @@ -0,0 +1,32 @@ +package com.instructure.horizon.features.notebook.common.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.instructure.horizon.R +import com.instructure.horizon.horizonui.organisms.Modal +import com.instructure.horizon.horizonui.organisms.ModalDialogState + +@Composable +fun NoteDeleteConfirmationDialog( + showDialog: Boolean, + onDeleteSelected: () -> Unit, + dismissDialog: () -> Unit, +) { + if (showDialog) { + Modal( + ModalDialogState( + title = stringResource(R.string.deleteNoteConfirmationTitle), + message = stringResource(R.string.deleteNoteConfirmationMessage), + primaryButtonTitle = stringResource(R.string.deleteNoteConfirmationDeleteLabel), + primaryButtonClick = { + dismissDialog() + onDeleteSelected() + }, + secondaryButtonTitle = stringResource(R.string.deleteNoteConfirmationCancelLabel), + secondaryButtonClick = { dismissDialog() } + + ), + onDismiss = { dismissDialog() } + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/DateFormat.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/DateFormat.kt new file mode 100644 index 0000000000..fed0afd0de --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/DateFormat.kt @@ -0,0 +1,24 @@ +/* + * 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.notebook.common.composable + +import com.instructure.pandautils.utils.localisedFormat +import java.util.Date + +fun Date.toNotebookLocalisedDateFormat(): String { + return this.localisedFormat("MMM dd, yyyy") +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookAppBar.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookAppBar.kt index 8f042626c8..3243e82ea9 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookAppBar.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookAppBar.kt @@ -16,14 +16,21 @@ */ package com.instructure.horizon.features.notebook.common.composable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults 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.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -41,26 +48,48 @@ import com.instructure.horizon.horizonui.molecules.IconButtonSize fun NotebookAppBar( modifier: Modifier = Modifier, navigateBack: (() -> Unit)? = null, - onClose: (() -> Unit)? = null + onClose: (() -> Unit)? = null, + centeredTitle: Boolean = false, + containerColor: Color = HorizonColors.Surface.pagePrimary(), ) { CenterAlignedTopAppBar( title = { - Text( - text = stringResource(R.string.notebookTitle), - style = HorizonTypography.h3, - color = HorizonColors.Text.title() - ) + if (centeredTitle) { + Text( + text = stringResource(R.string.notebookTitle), + style = HorizonTypography.h3, + color = HorizonColors.Text.title(), + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(R.drawable.edit_note), + contentDescription = null, + tint = HorizonColors.Icon.default(), + modifier = Modifier + .padding(start = 16.dp, end = 4.dp) + .size(20.dp) + ) + Text( + text = stringResource(R.string.notebookTitle), + style = HorizonTypography.h4, + color = HorizonColors.Text.title() + ) + } + } }, navigationIcon = { if (navigateBack != null) { IconButton( iconRes = R.drawable.arrow_back, contentDescription = stringResource(R.string.a11yNavigateBack), - color = IconButtonColor.Inverse, + color = IconButtonColor.Ghost, size = IconButtonSize.SMALL, - elevation = HorizonElevation.level4, onClick = navigateBack, - modifier = Modifier.padding(horizontal = 24.dp) + modifier = Modifier.padding(horizontal = 16.dp) ) } }, @@ -68,17 +97,17 @@ fun NotebookAppBar( if (onClose != null) { IconButton( iconRes = R.drawable.close, - contentDescription = stringResource(R.string.a11yNavigateBack), + contentDescription = stringResource(R.string.a11y_close), color = IconButtonColor.Inverse, size = IconButtonSize.SMALL, elevation = HorizonElevation.level4, onClick = onClose, - modifier = Modifier.padding(horizontal = 24.dp) + modifier = Modifier.padding(horizontal = 16.dp) ) } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = HorizonColors.Surface.pagePrimary(), + containerColor = containerColor, titleContentColor = HorizonColors.Text.title(), navigationIconContentColor = HorizonColors.Icon.default() ), @@ -93,4 +122,33 @@ private fun NotebookAppBarPreview() { NotebookAppBar( navigateBack = {} ) +} + +@Composable +@Preview +private fun NotebookAppBarClosePreview() { + ContextKeeper.appContext = LocalContext.current + NotebookAppBar( + onClose = {} + ) +} + +@Composable +@Preview +private fun NotebookAppBarCenteredPreview() { + ContextKeeper.appContext = LocalContext.current + NotebookAppBar( + navigateBack = {}, + centeredTitle = true + ) +} + +@Composable +@Preview +private fun NotebookAppBarCenteredClosePreview() { + ContextKeeper.appContext = LocalContext.current + NotebookAppBar( + onClose = {}, + centeredTitle = true + ) } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookHighlightedText.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookHighlightedText.kt index 17a02905c9..90561fef51 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookHighlightedText.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookHighlightedText.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.text.style.TextOverflow @@ -42,6 +43,9 @@ fun NotebookHighlightedText( var lineCount = 1 val lineList = mutableListOf() val lineColor = type?.color?.let { colorResource(type.color) } + val dashedEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + val pathEffect = if (type == NotebookType.Confusing) dashedEffect else null + Text( text = text, style = HorizonTypography.p1, @@ -68,7 +72,8 @@ fun NotebookHighlightedText( color = lineColor, strokeWidth = strokeWidth, start = Offset(0f, verticalOffset), - end = Offset(lineWidth, verticalOffset) + end = Offset(lineWidth, verticalOffset), + pathEffect = pathEffect ) drawLine( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookPill.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookPill.kt deleted file mode 100644 index da6c3daa2b..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookPill.kt +++ /dev/null @@ -1,65 +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.notebook.common.composable - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import com.instructure.canvasapi2.utils.ContextKeeper -import com.instructure.horizon.features.notebook.common.model.NotebookType -import com.instructure.horizon.horizonui.molecules.Pill -import com.instructure.horizon.horizonui.molecules.PillCase -import com.instructure.horizon.horizonui.molecules.PillSize -import com.instructure.horizon.horizonui.molecules.PillStyle -import com.instructure.horizon.horizonui.molecules.PillType - -@Composable -fun NotebookPill( - type: NotebookType, - modifier: Modifier = Modifier -) { - val pillType = when (type) { - NotebookType.Confusing -> PillType.DANGER - NotebookType.Important -> PillType.INSTITUTION - } - - Pill( - label = stringResource(type.labelRes), - style = PillStyle.OUTLINE, - type = pillType, - case = PillCase.UPPERCASE, - size = PillSize.REGULAR, - iconRes = type.iconRes, - modifier = modifier - ) -} - -@Composable -@Preview -private fun NotebookPillConfusingPreview() { - ContextKeeper.appContext = LocalContext.current - NotebookPill(type = NotebookType.Confusing) -} - -@Composable -@Preview -private fun NotebookPillImportantPreview() { - ContextKeeper.appContext = LocalContext.current - NotebookPill(type = NotebookType.Important) -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookTypeSelect.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookTypeSelect.kt index c41d9749e7..12165e50b3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookTypeSelect.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/composable/NotebookTypeSelect.kt @@ -16,119 +16,108 @@ */ package com.instructure.horizon.features.notebook.common.composable -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color 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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.horizon.R import com.instructure.horizon.features.notebook.common.model.NotebookType -import com.instructure.horizon.horizonui.foundation.HorizonBorder import com.instructure.horizon.horizonui.foundation.HorizonColors -import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius -import com.instructure.horizon.horizonui.foundation.HorizonSpace -import com.instructure.horizon.horizonui.foundation.HorizonTypography -import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.molecules.DropdownChip +import com.instructure.horizon.horizonui.molecules.DropdownItem @Composable fun NotebookTypeSelect( - type: NotebookType, - isSelected: Boolean, - onSelect: () -> Unit, - modifier: Modifier = Modifier + selected: NotebookType?, + onSelect: (NotebookType?) -> Unit, + showIcons: Boolean, + showAllOption: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, + verticalPadding: Dp = 6.dp ) { - val borderColor = if (isSelected) colorResource(type.color) else HorizonColors.LineAndBorder.containerStroke() - val iconColor = if (isSelected) colorResource(type.color) else HorizonColors.Icon.default() - val textColor = if (isSelected) colorResource(type.color) else HorizonColors.Text.body() - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .clip(HorizonCornerRadius.level2) - .border( - HorizonBorder.level1(color = borderColor), - HorizonCornerRadius.level2 + val context = LocalContext.current + val defaultBackgroundColor = HorizonColors.PrimitivesGrey.grey12() + val importantBgColor = HorizonColors.PrimitivesSea.sea12() + val confusingBgColor = HorizonColors.PrimitivesRed.red12() + val allNotesItem = DropdownItem( + value = null as NotebookType?, + label = context.getString(R.string.notebookTypeAllNotes), + iconRes = R.drawable.menu, + iconTint = HorizonColors.Icon.default(), + backgroundColor = defaultBackgroundColor + ) + val typeItems = remember { + buildList { + if (showAllOption) { + add(allNotesItem) + } + add( + DropdownItem( + value = NotebookType.Important, + label = context.getString(NotebookType.Important.labelRes), + iconRes = NotebookType.Important.iconRes, + iconTint = Color(context.getColor(NotebookType.Important.color)), + backgroundColor = importantBgColor + ) ) - .background(Color.White) - .clickable { - onSelect() - }, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(24.dp) - ) { - Icon( - painter = painterResource(type.iconRes), - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(24.dp) - ) - - HorizonSpace(SpaceSize.SPACE_8) - - Text( - text = stringResource(type.labelRes), - style = HorizonTypography.buttonTextLarge, - color = textColor, + add( + DropdownItem( + value = NotebookType.Confusing, + label = context.getString(NotebookType.Confusing.labelRes), + iconRes = NotebookType.Confusing.iconRes, + iconTint = Color(context.getColor(NotebookType.Confusing.color)), + backgroundColor = confusingBgColor + ) ) } } -} + val selectedTypeItem = + if (selected == null) allNotesItem else typeItems.find { it.value == selected } -@Composable -@Preview -private fun NotebookTypeSelectConfusingSelectedPreview() { - ContextKeeper.appContext = LocalContext.current - NotebookTypeSelect( - type = NotebookType.Confusing, - isSelected = true, - onSelect = {} + + DropdownChip( + items = typeItems, + selectedItem = selectedTypeItem, + onItemSelected = { item -> onSelect(item?.value) }, + placeholder = stringResource(R.string.notebookFilterTypePlaceholder), + dropdownWidth = 178.dp, + verticalPadding = verticalPadding, + showIconCollapsed = showIcons, + enabled = enabled, + borderColor = if (showIcons) { + selectedTypeItem?.iconTint ?: HorizonColors.LineAndBorder.lineStroke() + } else { + HorizonColors.LineAndBorder.lineStroke() + }, + contentColor = if (showIcons) { + selectedTypeItem?.iconTint ?: HorizonColors.Text.body() + } else { + HorizonColors.Text.body() + }, + modifier = modifier ) } @Composable @Preview -private fun NotebookTypeSelectConfusingNotSelectedPreview() { - ContextKeeper.appContext = LocalContext.current - NotebookTypeSelect( - type = NotebookType.Confusing, - isSelected = false, - onSelect = {} - ) +private fun NotebookTypeSelectAllPreview() { + NotebookTypeSelect(null, {}, true, true) } @Composable @Preview -private fun NotebookTypeSelectImportantSelectedPreview() { - ContextKeeper.appContext = LocalContext.current - NotebookTypeSelect( - type = NotebookType.Important, - isSelected = true, - onSelect = {} - ) +private fun NotebookTypeSelectImportantPreview() { + NotebookTypeSelect(NotebookType.Important, {}, true, true) } @Composable @Preview -private fun NotebookTypeSelectImportantNotSelectedPreview() { - ContextKeeper.appContext = LocalContext.current - NotebookTypeSelect( - type = NotebookType.Important, - isSelected = false, - onSelect = {} - ) +private fun NotebookTypeSelectConfusingPreview() { + NotebookTypeSelect(NotebookType.Confusing, {}, true, true) } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/model/NotebookType.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/model/NotebookType.kt index 81783b6315..73cecec714 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/model/NotebookType.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/model/NotebookType.kt @@ -10,6 +10,6 @@ enum class NotebookType( @DrawableRes val iconRes: Int, @ColorRes val color: Int ) { - Confusing(R.string.notebookTypeConfusing, R.drawable.help, R.color.icon_error), - Important(R.string.notebookTypeImportant, R.drawable.flag_2, R.color.icon_action), + Confusing(R.string.notebookTypeUnclear, R.drawable.help, R.color.icon_error), + Important(R.string.notebookTypeImportant, R.drawable.keep_pin, R.color.icon_action), } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/ComposeNotesHighlightingCanvasWebView.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/ComposeNotesHighlightingCanvasWebView.kt index 1acc60c200..e468d6d953 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/ComposeNotesHighlightingCanvasWebView.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/ComposeNotesHighlightingCanvasWebView.kt @@ -121,7 +121,7 @@ fun ComposeNotesHighlightingCanvasWebView( ) if (notes.none { intersects(it.highlightedText.textPosition.start to it.highlightedText.textPosition.end, selectedTextStart to selectedTextEnd) }){ add( - ActionMenuItem(2, context.getString(R.string.notesActionMenuAddImportantNote)) { + ActionMenuItem(2, context.getString(R.string.notesActionMenuMarkImportantNote)) { notesCallback.onNoteAdded( selectedText, NotebookType.Important.name, @@ -135,7 +135,7 @@ fun ComposeNotesHighlightingCanvasWebView( } ) add( - ActionMenuItem(3, context.getString(R.string.notesActionMenuAddConfusingNote)) { + ActionMenuItem(3, context.getString(R.string.notesActionMenuMarkConfusingNote)) { notesCallback.onNoteAdded( selectedText, NotebookType.Confusing.name, @@ -148,20 +148,6 @@ fun ComposeNotesHighlightingCanvasWebView( ) } ) - add( - ActionMenuItem(4, context.getString(R.string.notesActionMenuAddNote)) { - notesCallback.onNoteAdded( - selectedText, - null, - selectedTextRangeStartContainer, - selectedTextRangeStartOffset, - selectedTextRangeEndContainer, - selectedTextRangeEndOffset, - selectedTextStart, - selectedTextEnd - ) - } - ) } } } @@ -228,7 +214,7 @@ fun ComposeNotesHighlightingCanvasWebView( selectedTextStart = selectedTextStartParam selectedTextEnd = selectedTextEndParam }, - onHighlightedTextClick = { noteId, noteType, selectedText, userComment, startContainer, startOffset, endContainer, endOffset, selectedTextStartParam, selectedTextEndParam -> + onHighlightedTextClick = { noteId, noteType, selectedText, userComment, startContainer, startOffset, endContainer, endOffset, selectedTextStartParam, selectedTextEndParam, updatedAt -> lifecycleOwner.lifecycleScope.launch { notesCallback.onNoteSelected( noteId, @@ -240,7 +226,8 @@ fun ComposeNotesHighlightingCanvasWebView( endContainer, endOffset, selectedTextStartParam, - selectedTextEndParam + selectedTextEndParam, + updatedAt ) } }, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/JSTextSelectionInterface.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/JSTextSelectionInterface.kt index 03e51f8867..b1487d91cd 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/JSTextSelectionInterface.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/JSTextSelectionInterface.kt @@ -18,7 +18,10 @@ package com.instructure.horizon.features.notebook.common.webview import android.webkit.JavascriptInterface import android.webkit.WebView +import com.google.gson.Gson +import com.instructure.horizon.features.notebook.common.composable.toNotebookLocalisedDateFormat import com.instructure.horizon.features.notebook.common.model.Note +import org.json.JSONObject class JSTextSelectionInterface( private val onTextSelect: ( @@ -40,7 +43,8 @@ class JSTextSelectionInterface( endContainer: String, endOffset: Int, textSelectionStart: Int, - textSelectionEnd: Int + textSelectionEnd: Int, + updatedAt: String, ) -> Unit, private val onSelectionPositionChange: ( left: Float, @@ -83,14 +87,28 @@ class JSTextSelectionInterface( endOffset: Int, endContainer: String, textSelectionStart: Int, - textSelectionEnd: Int + textSelectionEnd: Int, + updatedAt: String, ) { - onHighlightedTextClick(noteId, noteType, selectedText, userComment, startContainer, startOffset, endContainer, endOffset, textSelectionStart, textSelectionEnd) + onHighlightedTextClick(noteId, noteType, selectedText, userComment, startContainer, startOffset, endContainer, endOffset, textSelectionStart, textSelectionEnd, updatedAt) } + data class HighlightParams( + val noteId: String, + val selectedText: String, + val userComment: String, + val startOffset: Int, + val startContainer: String, + val endOffset: Int, + val endContainer: String, + val noteReactionString: String, + val textSelectionStart: Int, + val textSelectionEnd: Int, + val updatedAt: String + ) + companion object { - private const val flagBase64Source = "url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTkiIHZpZXdCb3g9IjAgMCAxNiAxOSIgZmlsbD0iI0ZGRkZGRiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMiA5Ljc2OTI1VjE3Ljc1QzIgMTcuOTYyNSAxLjkyODA4IDE4LjE0MDYgMS43ODQyNSAxOC4yODQzQzEuNjQwNDIgMTguNDI4MSAxLjQ2MjI1IDE4LjUgMS4yNDk3NSAxOC41QzEuMDM3MDggMTguNSAwLjg1OSAxOC40MjgxIDAuNzE1NSAxOC4yODQzQzAuNTcxODMzIDE4LjE0MDYgMC41IDE3Ljk2MjUgMC41IDE3Ljc1VjEuNDA0QzAuNSAxLjE0NzgzIDAuNTg2NjY3IDAuOTMzMTY3IDAuNzYgMC43NjAwMDFDMC45MzMxNjcgMC41ODY2NjcgMS4xNDc4MyAwLjUgMS40MDQgMC41SDE0LjExMTVDMTQuMjc1NyAwLjUgMTQuNDIzNSAwLjUzOCAxNC41NTUgMC42MTRDMTQuNjg2NyAwLjY4OTgzMyAxNC43OTEyIDAuNzg4NTAxIDE0Ljg2ODUgMC45MTAwMDFDMTQuOTQ1OCAxLjAzMTUgMTQuOTkzOCAxLjE2NjY3IDE1LjAxMjUgMS4zMTU1QzE1LjAzMSAxLjQ2NDE3IDE1LjAwNjEgMS42MTU4MyAxNC45Mzc4IDEuNzcwNUwxMy40NTIgNS4xMzQ3NUwxNC45Mzc4IDguNDk4NzVDMTUuMDA2MSA4LjY1MzQyIDE1LjAzMSA4LjgwNTA4IDE1LjAxMjUgOC45NTM3NUMxNC45OTM4IDkuMTAyNTggMTQuOTQ1OCA5LjIzNzc1IDE0Ljg2ODUgOS4zNTkyNUMxNC43OTEyIDkuNDgwNzUgMTQuNjg2NyA5LjU3OTQyIDE0LjU1NSA5LjY1NTI1QzE0LjQyMzUgOS43MzEyNSAxNC4yNzU3IDkuNzY5MjUgMTQuMTExNSA5Ljc2OTI1SDJaTTIgOC4yNjkyNUgxMy4yMzI3TDEyLjE1IDUuODY1NUMxMi4wNDM3IDUuNjM4IDExLjk5MDUgNS4zOTQyNSAxMS45OTA1IDUuMTM0MjVDMTEuOTkwNSA0Ljg3NDI1IDEyLjA0MzcgNC42MzA3NSAxMi4xNSA0LjQwMzc1TDEzLjIzMjcgMkgyVjguMjY5MjVaIiBmaWxsPSIjRkZGRkZGIi8+PC9zdmc+Cg==)" - private const val questionBase64Source = "url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI0ZGRkZGRiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTQuOTU1OCA4LjAxNzE5QzE0Ljk1NTggNy4xOTE1MyAxNC42Nzk5IDYuNTI4MzYgMTQuMTI4IDYuMDI3NjlDMTMuNTc2IDUuNTI3MTkgMTIuODQ3NSA1LjI3Njk0IDExLjk0MjMgNS4yNzY5NEMxMS4zNjkzIDUuMjc2OTQgMTAuODYxMyA1LjM5Mzg2IDEwLjQxODMgNS42Mjc2OUM5Ljk3NTI5IDUuODYxNjkgOS41OTI4OCA2LjIxMzk0IDkuMjcxMDQgNi42ODQ0NEM5LjA4MTM4IDYuOTUyNDQgOC44Mjc4OCA3LjExMDc4IDguNTEwNTQgNy4xNTk0NEM4LjE5MzM4IDcuMjA4MjggNy45MTYxMiA3LjEzMDc4IDcuNjc4NzkgNi45MjY5NEM3LjUwMzI5IDYuNzc0MjggNy40MDMwNCA2LjU4Mjk0IDcuMzc4MDQgNi4zNTI5NEM3LjM1MzA0IDYuMTIyNzggNy40MDY1NCA1LjkwNDQ0IDcuNTM4NTQgNS42OTc5NEM4LjA0NjIxIDQuOTMwMTEgOC42NzQxMyA0LjM0NjE5IDkuNDIyMjkgMy45NDYxOUMxMC4xNzAzIDMuNTQ2MTkgMTEuMDEwMyAzLjM0NjE5IDExLjk0MjMgMy4zNDYxOUMxMy40MzA4IDMuMzQ2MTkgMTQuNjQyNCAzLjc3MjQ0IDE1LjU3NyA0LjYyNDk0QzE2LjUxMTUgNS40Nzc0NCAxNi45Nzg4IDYuNTg3NjkgMTYuOTc4OCA3Ljk1NTY5QzE2Ljk3ODggOC42ODAwMyAxNi44MjM3IDkuMzQzNzggMTYuNTEzNSA5Ljk0Njk0QzE2LjIwMzIgMTAuNTUwMyAxNS42NzQ0IDExLjE5OTQgMTQuOTI3IDExLjg5NDJDMTQuMjI3IDEyLjUyODkgMTMuNzQ5NSAxMy4wNDM5IDEzLjQ5NDMgMTMuNDM5NEMxMy4yMzkxIDEzLjgzNDkgMTMuMDkxIDE0LjI3OTQgMTMuMDUgMTQuNzcyOUMxMy4wMDkgMTUuMDU3NiAxMi44OTExIDE1LjI5NDggMTIuNjk2MyAxNS40ODQ0QzEyLjUwMTMgMTUuNjc0MyAxMi4yNjY2IDE1Ljc2OTIgMTEuOTkyMyAxNS43NjkyQzExLjcxOCAxNS43NjkyIDExLjQ4MzQgMTUuNjc1MyAxMS4yODg1IDE1LjQ4NzRDMTEuMDkzNyAxNS4yOTk2IDEwLjk5NjMgMTUuMDY4NSAxMC45OTYzIDE0Ljc5NDJDMTAuOTk2MyAxNC4xMjUgMTEuMTQ5MSAxMy41MTMyIDExLjQ1NDggMTIuOTU4N0MxMS43NjA2IDEyLjQwNDIgMTIuMjcyNSAxMS44MDc3IDEyLjk5MDUgMTEuMTY5MkMxMy43NTcyIDEwLjQ5NjIgMTQuMjc2NCA5LjkzNDYxIDE0LjU0OCA5LjQ4NDQ0QzE0LjgxOTkgOS4wMzQ0NCAxNC45NTU4IDguNTQ1MzYgMTQuOTU1OCA4LjAxNzE5Wk0xMS45NDIzIDIxLjQ5OTlDMTEuNTMzMyAyMS40OTk5IDExLjE4MSAyMS4zNTIyIDEwLjg4NTUgMjEuMDU2N0MxMC41OSAyMC43NjEyIDEwLjQ0MjMgMjAuNDA4OSAxMC40NDIzIDE5Ljk5OTlDMTAuNDQyMyAxOS41OTA5IDEwLjU5IDE5LjIzODcgMTAuODg1NSAxOC45NDMyQzExLjE4MSAxOC42NDc3IDExLjUzMzMgMTguNDk5OSAxMS45NDIzIDE4LjQ5OTlDMTIuMzUxMyAxOC40OTk5IDEyLjcwMzUgMTguNjQ3NyAxMi45OTkgMTguOTQzMkMxMy4yOTQ1IDE5LjIzODcgMTMuNDQyMyAxOS41OTA5IDEzLjQ0MjMgMTkuOTk5OUMxMy40NDIzIDIwLjQwODkgMTMuMjk0NSAyMC43NjEyIDEyLjk5OSAyMS4wNTY3QzEyLjcwMzUgMjEuMzUyMiAxMi4zNTEzIDIxLjQ5OTkgMTEuOTQyMyAyMS40OTk5WiIgZmlsbD0iI0ZGRkZGRiIvPjwvc3ZnPgo=)" + private val gson = Gson() private const val JS_INTERFACE_NAME = "TextSelectionInterface" private const val JS_CODE_FROM_WEB = """ let highlightCss = ` @@ -100,46 +118,14 @@ let highlightCss = ` text-decoration-color: rgba(14, 104, 179, 1); position: relative; } - .highlighted-important::before { - content: ""; - display: block; - position: absolute; - top: -6px; - left: -6px; - width: 12px; - height: 12px; - border-radius: 50%; - z-index: 10; - background-color: rgba(14, 104, 179, 1); - background-image: ${flagBase64Source}; - background-size: 60%; - background-repeat: no-repeat; - background-position: center; - } .highlighted-confusing { background-color: rgba(199, 31, 35, 0.2); text-decoration: underline; text-decoration-color: rgba(199, 31, 35, 1); - + text-decoration-style: dashed; position: relative; } - .highlighted-confusing::before { - content: ""; - display: block; - position: absolute; - top: -6px; - left: -6px; - width: 12px; - height: 12px; - border-radius: 50%; - z-index: 10; - background-color: rgba(199, 31, 35, 1); - background-image: ${questionBase64Source}; - background-size: 80%; - background-repeat: no-repeat; - background-position: center; - } ` const styleSheet = document.createElement("style"); styleSheet.innerText = highlightCss; @@ -664,7 +650,10 @@ const isNodeInRange = (range, node) => { """ private val JS_CODE = """ ${JS_CODE_FROM_WEB} -function highlightSelection(noteId, selectedText, userComment, startOffset, startContainer, endOffset, endContainer, noteReactionString, textSelectionStart, textSelectionEnd) { +function highlightSelection(paramsJson) { + const params = JSON.parse(paramsJson); + const { noteId, selectedText, userComment, startOffset, startContainer, endOffset, endContainer, noteReactionString, textSelectionStart, textSelectionEnd, updatedAt } = params; + let parent = document.getElementById("parent-container");//document.documentElement; if (!parent) return; @@ -689,7 +678,7 @@ function highlightSelection(noteId, selectedText, userComment, startOffset, star const highlightElement = document.createElement("span"); highlightElement.classList.add(highlightClassName); highlightElement.classList.add(cssClass); - highlightElement.onclick = function () { ${ JS_INTERFACE_NAME }.onHighlightedTextClicked(noteId, noteReactionString, selectedText, userComment, startOffset, startContainer, endOffset, endContainer, textSelectionStart, textSelectionEnd); }; + highlightElement.onclick = function () { ${ JS_INTERFACE_NAME }.onHighlightedTextClicked(noteId, noteReactionString, selectedText, userComment, startOffset, startContainer, endOffset, endContainer, textSelectionStart, textSelectionEnd, updatedAt); }; highlightElement.textContent = textNode.textContent; if (!highlightElement) return; @@ -743,7 +732,8 @@ javascript: (function () { endContainer: String, endOffset: Int, textSelectionStart: Int, - textSelectionEnd: Int + textSelectionEnd: Int, + updatedAt: String, ) -> Unit, onSelectionPositionChange: ( left: Float, @@ -762,7 +752,22 @@ javascript: (function () { fun WebView.highlightNotes(notes: List) { notes.forEach { note -> - val script = "javascript:highlightSelection('${note.id}', '${note.highlightedText.selectedText.replace("\n", "\\n")}', '${note.userText.replace("\n", "\\n")}', ${note.highlightedText.range.startOffset}, '${note.highlightedText.range.startContainer}', ${note.highlightedText.range.endOffset}, '${note.highlightedText.range.endContainer}', '${note.type.name}', ${note.highlightedText.textPosition.start}, ${note.highlightedText.textPosition.end})" + val params = HighlightParams( + noteId = note.id, + selectedText = note.highlightedText.selectedText, + userComment = note.userText, + startOffset = note.highlightedText.range.startOffset, + startContainer = note.highlightedText.range.startContainer, + endOffset = note.highlightedText.range.endOffset, + endContainer = note.highlightedText.range.endContainer, + noteReactionString = note.type.name, + textSelectionStart = note.highlightedText.textPosition.start, + textSelectionEnd = note.highlightedText.textPosition.end, + updatedAt = note.updatedAt.toNotebookLocalisedDateFormat() + ) + val paramsJson = gson.toJson(params) + val quotedJson = JSONObject.quote(paramsJson) + val script = "javascript:highlightSelection($quotedJson)" this.evaluateJavascript(script, null) } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/NotesCallback.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/NotesCallback.kt index 438b65b359..1575618422 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/NotesCallback.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/common/webview/NotesCallback.kt @@ -1,3 +1,19 @@ +/* + * 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.notebook.common.webview data class NotesCallback( @@ -11,7 +27,8 @@ data class NotesCallback( endContainer: String, endOffset: Int, textSelectionStart: Int, - textSelectionEnd: Int + textSelectionEnd: Int, + updatedAt: String, ) -> Unit, val onNoteAdded: ( selectedText: String, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookDialogNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookDialogNavigation.kt deleted file mode 100644 index aeddc7d74a..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookDialogNavigation.kt +++ /dev/null @@ -1,93 +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.notebook.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import com.instructure.horizon.features.notebook.NotebookScreen -import com.instructure.horizon.features.notebook.NotebookViewModel -import com.instructure.horizon.features.notebook.addedit.AddEditNoteScreen -import com.instructure.horizon.features.notebook.addedit.add.AddNoteViewModel -import com.instructure.horizon.features.notebook.addedit.edit.EditNoteViewModel -import com.instructure.horizon.horizonui.animation.enterTransition -import com.instructure.horizon.horizonui.animation.exitTransition -import com.instructure.horizon.horizonui.animation.popEnterTransition -import com.instructure.horizon.horizonui.animation.popExitTransition - -@Composable -fun NotebookDialogNavigation( - courseId: Long, - objectFilter: Pair, - onDismiss: () -> Unit, - onShowSnackbar: (String?, () -> Unit) -> Unit, -) { - val notebookDialogNavController = rememberNavController() - NavHost( - navController = notebookDialogNavController, - startDestination = NotebookRoute.Notebook.route, - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, - ) { - composable(NotebookRoute.Notebook.route) { - val viewModel = hiltViewModel() - val state by viewModel.uiState.collectAsState() - - LaunchedEffect(courseId, objectFilter) { - state.updateContent(courseId, objectFilter) - } - NotebookScreen( - mainNavController = notebookDialogNavController, - state = state, - onDismiss = { onDismiss() }, - onNoteSelected = { note -> - notebookDialogNavController.navigate( - NotebookRoute.EditNotebook( - noteId = note.id, - highlightedTextStartOffset = note.highlightedText.range.startOffset, - highlightedTextEndOffset = note.highlightedText.range.endOffset, - highlightedTextStartContainer = note.highlightedText.range.startContainer, - highlightedTextEndContainer = note.highlightedText.range.endContainer, - textSelectionStart = note.highlightedText.textPosition.start, - textSelectionEnd = note.highlightedText.textPosition.end, - highlightedText = note.highlightedText.selectedText, - noteType = note.type.name, - userComment = note.userText - ) - ) - } - ) - } - composable { - val viewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsState() - AddEditNoteScreen(notebookDialogNavController, uiState, onShowSnackbar) - } - composable { - val viewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsState() - AddEditNoteScreen(notebookDialogNavController, uiState, onShowSnackbar) - } - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookNavigation.kt index 651b98b12e..70eefb369b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookNavigation.kt @@ -18,11 +18,13 @@ package com.instructure.horizon.features.notebook.navigation import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.compose.navigation +import androidx.navigation.navArgument import com.instructure.horizon.features.notebook.NotebookScreen import com.instructure.horizon.features.notebook.NotebookViewModel import com.instructure.horizon.features.notebook.addedit.AddEditNoteScreen @@ -41,15 +43,42 @@ fun NavGraphBuilder.notebookNavigation( navigation( route = MainNavigationRoute.Notebook.route, startDestination = NotebookRoute.Notebook.route, - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() }, ) { - composable(NotebookRoute.Notebook.route) { + composable( + route = NotebookRoute.Notebook.route, + arguments = listOf( + navArgument(NotebookRoute.Notebook.COURSE_ID) { + type = NavType.StringType + nullable = true + }, + navArgument(NotebookRoute.Notebook.OBJECT_TYPE) { + type = NavType.StringType + nullable = true + }, + navArgument(NotebookRoute.Notebook.OBJECT_ID) { + type = NavType.StringType + nullable = true + }, + navArgument(NotebookRoute.Notebook.SHOW_TOP_BAR) { + type = NavType.BoolType + defaultValue = true + }, + navArgument(NotebookRoute.Notebook.SHOW_FILTERS) { + type = NavType.BoolType + defaultValue = true + }, + navArgument(NotebookRoute.Notebook.NAVIGATE_TO_EDIT) { + type = NavType.BoolType + defaultValue = false + } + ), + ) { val viewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsState() - NotebookScreen(navController, uiState) + NotebookScreen(navController, viewModel) } composable { val viewModel = hiltViewModel() @@ -62,4 +91,4 @@ fun NavGraphBuilder.notebookNavigation( AddEditNoteScreen(navController, uiState, onShowSnackbar) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookRoute.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookRoute.kt index c81eafc50e..38064e9d22 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookRoute.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/notebook/navigation/NotebookRoute.kt @@ -19,8 +19,44 @@ package com.instructure.horizon.features.notebook.navigation import kotlinx.serialization.Serializable @Serializable -sealed class NotebookRoute(val route: String) { - data object Notebook : NotebookRoute("notebook_list") +sealed class NotebookRoute(open val route: String) { + data class Notebook( + val courseId: String? = null, + val objectType: String? = null, + val objectId: String? = null, + val showTopBar: Boolean? = null, + val navigateToEdit: Boolean? = null + ) : NotebookRoute(route) { + companion object { + private const val ROUTE = "notebook_list" + const val COURSE_ID = "courseId" + const val OBJECT_TYPE = "objectType" + const val OBJECT_ID = "objectId" + const val SHOW_TOP_BAR = "showTopBar" + const val SHOW_FILTERS = "showFilters" + const val NAVIGATE_TO_EDIT = "navigateToEdit" + const val route = "$ROUTE?$COURSE_ID={$COURSE_ID}&$OBJECT_TYPE={$OBJECT_TYPE}&$OBJECT_ID={$OBJECT_ID}&$SHOW_TOP_BAR={$SHOW_TOP_BAR}&$SHOW_FILTERS={$SHOW_FILTERS}&$NAVIGATE_TO_EDIT={$NAVIGATE_TO_EDIT}" + + fun route( + courseId: String? = null, + objectType: String? = null, + objectId: String? = null, + showTopBar: Boolean? = null, + showFilters: Boolean? = null, + navigateToEdit: Boolean? = null + ): String { + val params = buildList { + courseId?.let { add("$COURSE_ID=$it") } + objectType?.let { add("$OBJECT_TYPE=$it") } + objectId?.let { add("$OBJECT_ID=$it") } + showTopBar?.let { add("$SHOW_TOP_BAR=$it") } + showFilters?.let { add("$SHOW_FILTERS=$it") } + navigateToEdit?.let { add("$NAVIGATE_TO_EDIT=$it") } + } + return if (params.isNotEmpty()) "$ROUTE?${params.joinToString("&")}" else ROUTE + } + } + } @Serializable data class AddNotebook( @@ -49,5 +85,6 @@ sealed class NotebookRoute(val route: String) { val highlightedText: String, val noteType: String, val userComment: String, + val updatedAt: String?, ): NotebookRoute("edit_notebook") } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/Extensions.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/Extensions.kt index dea6e9850e..2c3c47e158 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/Extensions.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/Extensions.kt @@ -16,11 +16,13 @@ package com.instructure.horizon.horizonui import android.content.Context +import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp import com.instructure.horizon.R fun SemanticsPropertyReceiver.expandable(context: Context, expanded: Boolean) { @@ -40,4 +42,7 @@ fun SemanticsPropertyReceiver.selectable(context: Context, selected: Boolean) { stateDescription = if (selected) selectedStateDesc else unselectedStateDesc liveRegion = LiveRegionMode.Assertive -} \ No newline at end of file +} + +val BoxWithConstraintsScope.isWideLayout + get() = this.maxWidth >= 500.dp \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/animation/NavigationTransation.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/animation/NavigationTransation.kt index ef92690f59..12413acdb3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/animation/NavigationTransation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/animation/NavigationTransation.kt @@ -16,30 +16,104 @@ */ package com.instructure.horizon.horizonui.animation +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import com.instructure.horizon.navigation.animationRules private const val animatedAlpha: Float = 0.0f private const val animatedScale: Float = 0.8f -val enterTransition = slideInHorizontally(initialOffsetX = { it }) + +private val slideEnterTransition = slideInHorizontally(initialOffsetX = { it }) + fadeIn(initialAlpha = animatedAlpha) -val exitTransition = slideOutHorizontally(targetOffsetX = { -it }) + +private val slideExitTransition = slideOutHorizontally(targetOffsetX = { -it }) + fadeOut(targetAlpha = animatedAlpha) -val popEnterTransition = slideInHorizontally(initialOffsetX = { -it }) + +private val slidePopEnterTransition = slideInHorizontally(initialOffsetX = { -it }) + fadeIn(initialAlpha = animatedAlpha) -val popExitTransition = slideOutHorizontally(targetOffsetX = { it/2 }) + +private val slidePopExitTransition = slideOutHorizontally(targetOffsetX = { it / 2 }) + fadeOut(targetAlpha = animatedAlpha) -val mainEnterTransition = fadeIn(initialAlpha = animatedAlpha) + +private val scaleEnterTransition = fadeIn(initialAlpha = animatedAlpha) + scaleIn(initialScale = animatedScale) -val mainExitTransition = fadeOut(targetAlpha = animatedAlpha) + - scaleOut(targetScale = animatedScale) \ No newline at end of file +private val scaleExitTransition = ExitTransition.None + +private val scalePopEnterTransition = EnterTransition.None + +private val scalePopExitTransition = fadeOut(targetAlpha = animatedAlpha) + + scaleOut(targetScale = animatedScale) + +enum class NavigationTransitionAnimation( + val enterTransition: EnterTransition, + val exitTransition: ExitTransition, + val popEnterTransition: EnterTransition, + val popExitTransition: ExitTransition +) { + SLIDE( + enterTransition = slideEnterTransition, + exitTransition = slideExitTransition, + popEnterTransition = slidePopEnterTransition, + popExitTransition = slidePopExitTransition + ), + SCALE( + enterTransition = scaleEnterTransition, + exitTransition = scaleExitTransition, + popEnterTransition = scalePopEnterTransition, + popExitTransition = scalePopExitTransition + ) +} + + +fun AnimatedContentTransitionScope.enterTransition( + defaultTransitionStyle: NavigationTransitionAnimation = NavigationTransitionAnimation.SLIDE +): EnterTransition { + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + + return (findAnimationStyle(fromRoute, toRoute) ?: defaultTransitionStyle).enterTransition +} + +fun AnimatedContentTransitionScope.exitTransition( + defaultTransitionStyle: NavigationTransitionAnimation = NavigationTransitionAnimation.SLIDE +): ExitTransition { + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + + return (findAnimationStyle(fromRoute, toRoute) ?: defaultTransitionStyle).exitTransition +} + +fun AnimatedContentTransitionScope.popEnterTransition( + defaultTransitionStyle: NavigationTransitionAnimation = NavigationTransitionAnimation.SLIDE +): EnterTransition { + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + + return (findAnimationStyle(toRoute, fromRoute) ?: defaultTransitionStyle).popEnterTransition +} + +fun AnimatedContentTransitionScope.popExitTransition( + defaultTransitionStyle: NavigationTransitionAnimation = NavigationTransitionAnimation.SLIDE +): ExitTransition { + val fromRoute = initialState.destination.route + val toRoute = targetState.destination.route + + return (findAnimationStyle(toRoute, fromRoute) ?: defaultTransitionStyle).popExitTransition +} + +private fun findAnimationStyle(fromRoute: String?, toRoute: String?): NavigationTransitionAnimation? { + return animationRules.firstOrNull { rule -> + val fromMatches = rule.from == null || fromRoute?.startsWith(rule.from) == true + val toMatches = rule.to == null || toRoute?.startsWith(rule.to) == true + fromMatches && toMatches + }?.style +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonBorder.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonBorder.kt index e5504ee9bd..91816e8464 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonBorder.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonBorder.kt @@ -1,10 +1,142 @@ package com.instructure.horizon.horizonui.foundation import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp object HorizonBorder { fun level1(color: Color = HorizonColors.LineAndBorder.lineStroke()) = BorderStroke(1.dp, color) fun level2(color: Color = HorizonColors.LineAndBorder.lineStroke()) = BorderStroke(2.dp, color) -} \ No newline at end of file +} + +fun Modifier.horizonBorder( + color: Color, + start: Dp = 0.dp, + top: Dp = 0.dp, + end: Dp = 0.dp, + bottom: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return this + .padding(start, top, end, bottom) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width - start.roundToPx() - end.roundToPx() + val height = placeable.height - top.roundToPx() - bottom.roundToPx() + + layout(width, height) { + placeable.place(-start.roundToPx(), -top.roundToPx()) + } + } + .dropShadow(RoundedCornerShape(cornerRadius)) { + this.color = color + this.radius = 0f + } + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width + start.roundToPx() + end.roundToPx() + val height = placeable.height + top.roundToPx() + bottom.roundToPx() + + layout(width, height) { + placeable.place(start.roundToPx(), top.roundToPx()) + } + } +} + +fun Modifier.horizonBorderShadow( + color: Color, + start: Dp = 0.dp, + top: Dp = 0.dp, + end: Dp = 0.dp, + bottom: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + val maxShadow = maxOf(start, top, end, bottom) + + return this + .padding(start, top, end, bottom) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width - start.roundToPx() - end.roundToPx() + val height = placeable.height - top.roundToPx() - bottom.roundToPx() + + layout(width, height) { + placeable.place(-start.roundToPx(), -top.roundToPx()) + } + } + .dropShadow(RoundedCornerShape(cornerRadius)) { + this.color = color.copy(0.1f) + this.radius = maxShadow.toPx() + + val offsetX = if (start == 0.dp) { + this.radius = maxShadow.toPx() / 2 + radius + } else if (end == 0.dp) { + this.radius = maxShadow.toPx() / 2 + -radius + } else { + 0f + } + val offsetY = if (top == 0.dp) { + this.radius = maxShadow.toPx() / 2 + radius + } else if (bottom == 0.dp) { + this.radius = maxShadow.toPx() / 2 + -radius + } else { + 0f + } + this.offset = Offset(offsetX, offsetY) + } + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = placeable.width + start.roundToPx() + end.roundToPx() + val height = placeable.height + top.roundToPx() + bottom.roundToPx() + + layout(width, height) { + placeable.place(start.roundToPx(), top.roundToPx()) + } + } +} + +fun Modifier.horizonBorder( + color: Color, + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return horizonBorder(color, horizontal, vertical, horizontal, vertical, cornerRadius) +} + +fun Modifier.horizonBorder( + color: Color, + all: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return horizonBorder(color, all, all, cornerRadius) +} + +fun Modifier.horizonBorderShadow( + color: Color, + horizontal: Dp = 0.dp, + vertical: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return horizonBorderShadow(color, horizontal, vertical, horizontal, vertical, cornerRadius) +} + +fun Modifier.horizonBorderShadow( + color: Color, + all: Dp = 0.dp, + cornerRadius: Dp = 0.dp, +): Modifier { + return horizonBorderShadow(color, all, all, cornerRadius) +} + diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonColors.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonColors.kt index af8f653b7c..f4b25150a2 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonColors.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/foundation/HorizonColors.kt @@ -245,6 +245,8 @@ object HorizonColors { } object PrimitivesSea { + @Composable + fun sea12() = colorResource(R.color.primitives_sea12) @Composable fun sea30() = colorResource(R.color.primitives_sea30) @Composable diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/DropdownChip.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/DropdownChip.kt new file mode 100644 index 0000000000..4b75991219 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/DropdownChip.kt @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2025 - 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.horizon.horizonui.molecules + +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.instructure.horizon.R +import com.instructure.horizon.horizonui.foundation.HorizonBorder +import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius +import com.instructure.horizon.horizonui.foundation.HorizonTypography +import com.instructure.horizon.horizonui.organisms.inputs.common.InputDropDownPopup +import com.instructure.pandautils.compose.modifiers.conditional + +data class DropdownItem( + val value: T, + val label: String, + @DrawableRes val iconRes: Int? = null, + val iconTint: Color? = null, + val backgroundColor: Color? = null +) + +@Composable +fun DropdownChip( + items: List>, + selectedItem: DropdownItem?, + onItemSelected: (DropdownItem?) -> Unit, + modifier: Modifier = Modifier, + dropdownWidth: Dp? = null, + placeholder: String, + showIconCollapsed: Boolean = false, + enabled: Boolean = true, + borderColor: Color = HorizonColors.LineAndBorder.lineStroke(), + contentColor: Color = HorizonColors.Text.body(), + verticalPadding: Dp = 0.dp +) { + var isMenuOpen by remember { mutableStateOf(false) } + val localDensity = LocalDensity.current + var heightInPx by remember { mutableIntStateOf(0) } + var width by remember { mutableStateOf(dropdownWidth) } + val iconRotation = animateIntAsState( + targetValue = if (isMenuOpen) 180 else 0, + label = "iconRotation" + ) + + val expandedState = stringResource(R.string.a11y_expanded) + val collapsedState = stringResource(R.string.a11y_collapsed) + + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background( + if (isMenuOpen) { + HorizonColors.PrimitivesGrey.grey12() + } else { + HorizonColors.Surface.cardPrimary() + }, shape = HorizonCornerRadius.level1 + ) + .border( + HorizonBorder.level1(color = borderColor), + HorizonCornerRadius.level1 + ) + .clip(HorizonCornerRadius.level1) + .conditional(enabled) { clickable { isMenuOpen = !isMenuOpen } } + .padding(horizontal = 8.dp, vertical = 2.dp) + .onGloballyPositioned { + heightInPx = it.size.height + if (dropdownWidth == null) { + width = with(localDensity) { it.size.width.toDp() } + } + } + .clearAndSetSemantics { + role = Role.DropdownList + stateDescription = if (isMenuOpen) expandedState else collapsedState + contentDescription = selectedItem?.label ?: placeholder + } + ) { + if (showIconCollapsed && selectedItem?.iconRes != null) { + Icon( + painter = painterResource(selectedItem.iconRes), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .padding(2.dp), + tint = selectedItem.iconTint ?: HorizonColors.Icon.default() + ) + } + Text( + text = selectedItem?.label ?: placeholder, + style = HorizonTypography.p2, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f, false) + .padding( + end = 2.dp, + top = verticalPadding, + bottom = verticalPadding + ) + ) + + if (enabled) { + Icon( + painter = painterResource(R.drawable.keyboard_arrow_down), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .rotate(iconRotation.value.toFloat()), + tint = contentColor + ) + } + } + + if (enabled) { + InputDropDownPopup( + isMenuOpen = isMenuOpen, + options = items, + width = width, + verticalOffsetPx = heightInPx, + onMenuOpenChanged = { isMenuOpen = it }, + onOptionSelected = { item -> + if (selectedItem != item) { + onItemSelected(item) + } + }, + item = { item -> + DropdownChipItem(item, selectedItem) + } + ) + } + } +} + +@Composable +private fun DropdownChipItem( + item: DropdownItem, + selectedItem: DropdownItem? +) { + val isSelected = selectedItem?.value == item.value + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .conditional(isSelected && item.backgroundColor != null) { + background( + item.backgroundColor!!, + ) + } + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + item.iconRes?.let { iconRes -> + Icon( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .padding(1.dp), + tint = if (isSelected) { + item.iconTint ?: HorizonColors.Icon.default() + } else { + HorizonColors.Icon.default() + } + ) + } + Text( + text = item.label, + style = HorizonTypography.p1, + color = if (isSelected && item.backgroundColor != null) { + item.iconTint ?: HorizonColors.Text.body() + } else { + HorizonColors.Text.body() + }, + modifier = Modifier.padding(start = 4.dp) + ) + } +} + +@Composable +@Preview +private fun DropdownChipPreview() { + val items = listOf( + DropdownItem( + value = "important", + label = "Important", + iconRes = R.drawable.flag_2, + iconTint = HorizonColors.Icon.action(), + backgroundColor = HorizonColors.PrimitivesSea.sea12() + ), + DropdownItem( + value = "unclear", + label = "Unclear", + iconRes = R.drawable.help, + iconTint = HorizonColors.Icon.error() + ) + ) + + var selectedItem by remember { mutableStateOf?>(items[0]) } + + DropdownChip( + items = items, + selectedItem = selectedItem, + onItemSelected = { selectedItem = it }, + dropdownWidth = 140.dp, + verticalPadding = 6.dp, + placeholder = "Type" + ) +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/IconButton.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/IconButton.kt index a8cc113d15..b523d3aa49 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/IconButton.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/molecules/IconButton.kt @@ -16,10 +16,12 @@ package com.instructure.horizon.horizonui.molecules import androidx.annotation.DrawableRes +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon @@ -170,6 +172,55 @@ fun IconButton( } } +@Composable +fun LoadingIconButton( + @DrawableRes iconRes: Int, + loading: Boolean, + modifier: Modifier = Modifier, + size: IconButtonSize = IconButtonSize.NORMAL, + color: IconButtonColor = IconButtonColor.Black, + elevation: Dp? = null, + enabled: Boolean = true, + contentDescription: String? = null, + onClick: () -> Unit = {}, + contentAlignment: Alignment = Alignment.Center, + badge: @Composable (() -> Unit)? = null +) { + Box( + contentAlignment = contentAlignment, + modifier = modifier + .animateContentSize() + ) { + if (loading) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(color = color.backgroundColor, shape = HorizonCornerRadius.level6) + ) { + Spinner( + size = SpinnerSize.EXTRA_SMALL, + color = color.iconColor, + modifier = Modifier + .align(Alignment.Center) + .padding(8.dp), + ) + } + } else { + IconButton( + iconRes = iconRes, + modifier = modifier, + size = size, + color = color, + elevation = elevation, + enabled = enabled, + contentDescription = contentDescription, + onClick = onClick, + badge = badge + ) + } + } +} + @Composable @Preview(showBackground = true) private fun IconButtonPreview() { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/CollapsableHeaderScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/CollapsableHeaderScreen.kt index 865ac2dfc0..a8961ce30a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/CollapsableHeaderScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/CollapsableHeaderScreen.kt @@ -17,8 +17,17 @@ package com.instructure.horizon.horizonui.organisms import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FabPosition +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -29,11 +38,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max @@ -65,7 +77,9 @@ fun CollapsableHeaderScreen( moduleHeaderHeight = coordinates.size.height val temp = nestedScrollConnection.appBarOffset nestedScrollConnection = - CollapsingAppBarNestedScrollConnection(moduleHeaderHeight).apply { appBarOffset = temp } + CollapsingAppBarNestedScrollConnection(moduleHeaderHeight).apply { + appBarOffset = temp + } } } ) { @@ -82,6 +96,115 @@ fun CollapsableHeaderScreen( } } +@Composable +fun CollapsableScaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + containerColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = contentColorFor(containerColor), + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + content: @Composable () -> Unit, +) { + + val scrollConnectionSaver = + Saver, Pair, Pair>>( + save = { + (it.value.topBarMaxHeight to it.value.topBarOffset) to + (it.value.bottomBarMaxHeight to it.value.bottomBarOffset) + }, + restore = { + (mutableStateOf(CollapsingBarsNestedScrollConnection(it.first.first, it.second.first).apply { + topBarOffset = it.first.second + bottomBarOffset = it.second.second + })) + } + ) + val density = LocalDensity.current + + var topBarHeight by rememberSaveable { mutableIntStateOf(0) } + var bottomBarHeight by rememberSaveable { mutableIntStateOf(0) } + + var nestedScrollConnection by rememberSaveable(saver = scrollConnectionSaver) { + mutableStateOf(CollapsingBarsNestedScrollConnection(topBarHeight, bottomBarHeight)) + } + + val collapsedTopBarPadding = max( + 0.dp, + with(density) { topBarHeight.toDp() } + with(density) { nestedScrollConnection.topBarOffset.toDp() }) + + val collapsedBottomBarPadding = max( + 0.dp, + with(density) { bottomBarHeight.toDp() } - with(density) { nestedScrollConnection.bottomBarOffset.toDp() }) + + Scaffold( + modifier = modifier + .nestedScroll(nestedScrollConnection), + topBar = { + Box( + Modifier.offset { IntOffset(0, nestedScrollConnection.topBarOffset) } + .onGloballyPositioned { coordinates -> + if (coordinates.size.height != topBarHeight) { + topBarHeight = coordinates.size.height + val tempTop = nestedScrollConnection.topBarOffset + val tempBottom = nestedScrollConnection.bottomBarOffset + nestedScrollConnection = + CollapsingBarsNestedScrollConnection(topBarHeight, bottomBarHeight).apply { + topBarOffset = tempTop + bottomBarOffset = tempBottom + } + } + } + ) { + topBar() + } + }, + bottomBar = { + Box( + Modifier.offset { IntOffset(0, nestedScrollConnection.bottomBarOffset) } + .onGloballyPositioned { coordinates -> + if (coordinates.size.height != bottomBarHeight) { + bottomBarHeight = coordinates.size.height + val tempTop = nestedScrollConnection.topBarOffset + val tempBottom = nestedScrollConnection.bottomBarOffset + nestedScrollConnection = + CollapsingBarsNestedScrollConnection(topBarHeight, bottomBarHeight).apply { + topBarOffset = tempTop + bottomBarOffset = tempBottom + } + } + } + ) { + bottomBar() + } + }, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + containerColor = containerColor, + contentColor = contentColor, + contentWindowInsets = contentWindowInsets, + content = { paddingValues -> + val layoutDirection = LocalLayoutDirection.current + Box( + modifier = Modifier.padding( + PaddingValues( + start = paddingValues.calculateStartPadding(layoutDirection), + end = paddingValues.calculateEndPadding(layoutDirection), + top = collapsedTopBarPadding, + bottom = collapsedBottomBarPadding + ) + ).testTag("collapsableContent") + ) { + content() + } + } + ) +} + private class CollapsingAppBarNestedScrollConnection( val appBarMaxHeight: Int ) : NestedScrollConnection { @@ -96,4 +219,30 @@ private class CollapsingAppBarNestedScrollConnection( val consumed = appBarOffset - previousOffset return Offset(0f, consumed.toFloat()) } +} + +private class CollapsingBarsNestedScrollConnection( + val topBarMaxHeight: Int, + val bottomBarMaxHeight: Int +) : NestedScrollConnection { + + var topBarOffset: Int by mutableIntStateOf(0) + var bottomBarOffset: Int by mutableIntStateOf(0) + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.y.toInt() + + val topNewOffset = topBarOffset + delta + val topPreviousOffset = topBarOffset + topBarOffset = topNewOffset.coerceIn(-topBarMaxHeight, 0) + val topConsumed = topBarOffset - topPreviousOffset + + val bottomNewOffset = bottomBarOffset - delta + val bottomPreviousOffset = bottomBarOffset + bottomBarOffset = bottomNewOffset.coerceIn(0, bottomBarMaxHeight) + val bottomConsumed = bottomBarOffset - bottomPreviousOffset + + val totalConsumed = topConsumed + bottomConsumed + return Offset(0f, totalConsumed.toFloat()) + } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/Modal.kt b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/Modal.kt index 1457ee3996..160c91134f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/Modal.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/horizonui/organisms/Modal.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -104,6 +105,7 @@ private fun DialogHeader( Text(text = title, style = HorizonTypography.h3, modifier = Modifier.weight(1f)) IconButton( iconRes = R.drawable.close, + contentDescription = stringResource(R.string.a11y_close), size = IconButtonSize.SMALL, color = IconButtonColor.Inverse, elevation = HorizonElevation.level4, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigation.kt b/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigation.kt index b573a35e8d..7691c50df2 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigation.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigation.kt @@ -27,7 +27,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -47,6 +47,7 @@ import com.instructure.horizon.horizonui.animation.enterTransition import com.instructure.horizon.horizonui.animation.exitTransition import com.instructure.horizon.horizonui.animation.popEnterTransition import com.instructure.horizon.horizonui.animation.popExitTransition +import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.navigation.MainNavigationRoute.Companion.ASSIGNMENT_ID import com.instructure.horizon.navigation.MainNavigationRoute.Companion.COURSE_ID import com.instructure.horizon.navigation.MainNavigationRoute.Companion.PAGE_ID @@ -87,13 +88,14 @@ fun HorizonNavigation(navController: NavHostController, modifier: Modifier = Mod val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } Scaffold( + containerColor = HorizonColors.Surface.pagePrimary(), snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> NavHost( - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() }, modifier = modifier.padding(innerPadding), navController = navController, startDestination = MainNavigationRoute.Home.route @@ -112,7 +114,12 @@ fun HorizonNavigation(navController: NavHostController, modifier: Modifier = Mod composable(MainNavigationRoute.Home.route) { HomeScreen(navController, hiltViewModel()) } - composable { + composable( + enterTransition = { enterTransition() }, + exitTransition = { exitTransition() }, + popEnterTransition = { popEnterTransition() }, + popExitTransition = { popExitTransition() } + ) { val viewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsState() ModuleItemSequenceScreen(navController, uiState) @@ -233,4 +240,5 @@ fun HorizonNavigation(navController: NavHostController, modifier: Modifier = Mod } } } -} \ No newline at end of file +} + diff --git a/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigationTransitionRules.kt b/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigationTransitionRules.kt new file mode 100644 index 0000000000..4e67353a42 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/navigation/HorizonNavigationTransitionRules.kt @@ -0,0 +1,90 @@ +/* + * 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.navigation + +import com.instructure.horizon.features.account.navigation.AccountRoute +import com.instructure.horizon.features.home.HomeNavigationRoute +import com.instructure.horizon.features.inbox.navigation.HorizonInboxRoute +import com.instructure.horizon.features.notebook.navigation.NotebookRoute +import com.instructure.horizon.horizonui.animation.NavigationTransitionAnimation + +data class NavigationTransitionAnimationRule( + val from: String? = null, + val to: String? = null, + val style: NavigationTransitionAnimation, +) + +val animationRules = listOf( + //Notebook + NavigationTransitionAnimationRule( + from = NotebookRoute.Notebook.route, + to = NotebookRoute.EditNotebook.serializableRoute, + style = NavigationTransitionAnimation.SCALE, + ), + NavigationTransitionAnimationRule( + from = NotebookRoute.Notebook.route, + to = NotebookRoute.AddNotebook.serializableRoute, + style = NavigationTransitionAnimation.SCALE + ), + NavigationTransitionAnimationRule( + from = MainNavigationRoute.ModuleItemSequence.serializableRoute, + to = NotebookRoute.Notebook.route, + style = NavigationTransitionAnimation.SCALE + ), + + //Account + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.Advanced.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.BugReportWebView.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.CalendarFeed.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.Notifications.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.Password.route, + style = NavigationTransitionAnimation.SLIDE + ), + NavigationTransitionAnimationRule( + from = HomeNavigationRoute.Account.route, + to = AccountRoute.Profile.route, + style = NavigationTransitionAnimation.SLIDE + ), + + //Inbox + NavigationTransitionAnimationRule( + from = null, + to = HorizonInboxRoute.InboxCompose.route, + style = NavigationTransitionAnimation.SCALE + ), +) + +private val Any.serializableRoute: String + get() = this::class.java.name.replace("$", ".").replace(".Companion", "") \ No newline at end of file diff --git a/libs/horizon/src/main/res/values-ar/strings.xml b/libs/horizon/src/main/res/values-ar/strings.xml index 8a038748d1..4ef3a26107 100644 --- a/libs/horizon/src/main/res/values-ar/strings.xml +++ b/libs/horizon/src/main/res/values-ar/strings.xml @@ -389,10 +389,36 @@ اكتمل %1$d\%% لم نتمكن من تحميل هذا المحتوى. يرجى إعادة المحاولة. - إعادة المحاولة - مرحبًا! اعرض برنامجك للتسجيل في المساق الأول لك. + تحديث تفاصيل البرنامج - تهانينا! لقد قمت بإكمال المساق الخاص بك. استعرض تقدمك ودرجاتك في صفحة "التعلم". + الوقت + سيتم تحديث عنصر واجهة المستخدم هذا بمجرد توفر البيانات. + لم نتمكن من تحميل هذا المحتوى.\nيُرجى إعادة المحاولة. + تحديث + ساعات في + ساعات في المساق الخاص بك + جميع المساقات + أبرز المهارات + لا توجد بيانات بعد + سيتم تحديث عنصر واجهة المستخدم هذا بمجرد توفر البيانات. + لم نتمكن من تحميل هذا المحتوى. + يرجى إعادة المحاولة. + تحديث + مبتدئ + كفؤ + متقدم + خبير + تهانينا! لقد قمت بإكمال أول مساق لك. استعرض تقدمك ودرجاتك في صفحة "التعلم". + الأنشطة + مكتمل + سيتم تحديث عنصر واجهة المستخدم هذا بمجرد توفر البيانات. + لم نتمكن من تحميل هذا المحتوى.\nيُرجى إعادة المحاولة. + تحديث + المهارات + المكتسبة + سيتم تحديث عنصر واجهة المستخدم هذا بمجرد توفر البيانات. + تحديث + لم نتمكن من تحميل هذا المحتوى.\nيُرجى إعادة المحاولة. تحديد مساق إغلاق تم التوسيع @@ -403,4 +429,22 @@ غير مكتمل مؤمّن تم إلغاء التحديد + من %1$s + الانتقال إلى الإعلان + لم نتمكن من تحميل هذا المحتوى.\nيُرجى إعادة المحاولة. + %1$d من %2$d + الإعلان السابقة + الإعلان التالي + البرنامج + أهلاً بك في %1$s اعرض برنامجك للتسجيل في المساق الأول لك. + أنت غير مسجل حاليًا في مساق. + يتم تحميل عنصر واجهة المستخدم %1$s + المساقات + تم إكمال %1$d + تم اكتساب %1$d + %1$d ساعات في %2$s + %1$d من الساعات في المساق الخاص بك + تحديث + العنصر التالي + العنصر السابق diff --git a/libs/horizon/src/main/res/values-b+da+DK+instk12/strings.xml b/libs/horizon/src/main/res/values-b+da+DK+instk12/strings.xml index f6a0d2d046..3ac81fb728 100644 --- a/libs/horizon/src/main/res/values-b+da+DK+instk12/strings.xml +++ b/libs/horizon/src/main/res/values-b+da+DK+instk12/strings.xml @@ -365,10 +365,36 @@ %1$d\%% afsluttet Vi kunne ikke indlæse dette indhold. Prøv igen. - Prøv igen - Velkommen! Se dit program for at tilmelde dig dit første fag. + Opdater Programdetaljer + Tidspunkt + Denne widget opdateres, når data bliver tilgængelige. + Vi kunne ikke indlæse dette indhold.\nPrøv igen. + Opdater + timer i + timer i dit fag + alle fag + Færdighedshøjdepunkter + Ingen data endnu + Denne widget opdateres, når data bliver tilgængelige. + Vi kunne ikke indlæse dette indhold. + Prøv igen. + Opdater + Nybegynder + Kompetent + Avanceret + Ekspert Tillykke! Du har afsluttet dit fag. Se dine fremskridt og resultater på siden Lær. + Aktiviteter + fuldført + Denne widget opdateres, når data bliver tilgængelige. + Vi kunne ikke indlæse dette indhold.\nPrøv igen. + Opdater + Færdigheder + optjent + Denne widget opdateres, når data bliver tilgængelige. + Opdater + Vi kunne ikke indlæse dette indhold.\nPrøv igen. Vælg fag Luk Udvidet @@ -379,4 +405,22 @@ Ikke afsluttet Låst Ikke valgt + Fra %1$s + Gå til besked + Vi kunne ikke indlæse dette indhold.\nPrøv igen. + %1$d af %2$d + Tidligere besked + Næste besked + Program + Velkommen til %1$s! Se dit program for at tilmelde dig dit første fag. + Du er ikke tilmeldt et fag i øjeblikket. + Widgeten %1$s indlæses + Fag + %1$d afsluttet + %1$d optjent + %1$d timer i %2$s + %1$d timer i dit fag + Opdater + Næste element + Forrige element diff --git a/libs/horizon/src/main/res/values-b+en+AU+unimelb/strings.xml b/libs/horizon/src/main/res/values-b+en+AU+unimelb/strings.xml index 1d4dce5360..23fcfb990d 100644 --- a/libs/horizon/src/main/res/values-b+en+AU+unimelb/strings.xml +++ b/libs/horizon/src/main/res/values-b+en+AU+unimelb/strings.xml @@ -365,10 +365,36 @@ %1$d\%% complete We weren’t able to load this content. Please try again. - Retry - Welcome! View your program to enrol in your first subject. + Refresh Program details - Congrats! You’ve completed your subject. View your progress and scores on the Learn page. + Time + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + hours in + hours in your subject + all subjects + Skill Highlights + No data yet + This widget will update once data becomes available. + We weren\'t able to load this content. + Please try again. + Refresh + Beginner + Proficient + Advanced + Expert + Congrats! You\'ve completed your subject. View your progress and scores on the Learn page. + Activities + completed + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + Skills + earned + This widget will update once data becomes available. + Refresh + We weren\'t able to load this content.\nPlease try again. Select Subject Close Expanded @@ -379,4 +405,22 @@ Not completed Locked Unselected + From %1$s + Go to announcement + We weren\'t able to load this content.\nPlease try again. + %1$d of %2$d + Previous announcement + Next announcement + Program + Welcome to %1$s! View your program to enrol in your first subject. + You aren’t currently enrolled in a subject. + %1$s widget is loading + Subjects + %1$d completed + %1$d earned + %1$d hours in %2$s + %1$d hours in your subject + Refresh + Next item + Previous item diff --git a/libs/horizon/src/main/res/values-b+en+GB+instukhe/strings.xml b/libs/horizon/src/main/res/values-b+en+GB+instukhe/strings.xml index ff4565981d..4052f08ca1 100644 --- a/libs/horizon/src/main/res/values-b+en+GB+instukhe/strings.xml +++ b/libs/horizon/src/main/res/values-b+en+GB+instukhe/strings.xml @@ -365,10 +365,36 @@ %1$d\%% complete We weren’t able to load this content. Please try again. - Retry - Welcome! View your program to enrol in your first module. + Refresh Program details - Congrats! You’ve completed your module. View your progress and scores on the Learn page. + Time + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + hours in + hours in your module + all modules + Skill Highlights + No data yet + This widget will update once data becomes available. + We weren\'t able to load this content. + Please try again. + Refresh + Beginner + Proficient + Advanced + Expert + Congrats! You\'ve completed your module. View your progress and scores on the Learn page. + Activities + completed + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + Skills + earned + This widget will update once data becomes available. + Refresh + We weren\'t able to load this content.\nPlease try again. Select Module Close Expanded @@ -379,4 +405,22 @@ Not completed Locked Unselected + From %1$s + Go to announcement + We weren\'t able to load this content.\nPlease try again. + %1$d of %2$d + Previous announcement + Next announcement + Programme + Welcome to %1$s! View your program to enrol in your first module. + You aren’t currently enrolled in a module. + %1$s widget is loading + Modules + %1$d completed + %1$d earned + %1$d hours in %2$s + %1$d hours in your module + Refresh + Next item + Previous item diff --git a/libs/horizon/src/main/res/values-b+nb+NO+instk12/strings.xml b/libs/horizon/src/main/res/values-b+nb+NO+instk12/strings.xml index 9f63a57985..8105ca3ff2 100644 --- a/libs/horizon/src/main/res/values-b+nb+NO+instk12/strings.xml +++ b/libs/horizon/src/main/res/values-b+nb+NO+instk12/strings.xml @@ -365,10 +365,36 @@ %1$d\%% fullført Vi kunne ikke laste inn dette innholdet. Prøv på nytt. - Forsøk igjen - Velkommen! Vis programmet ditt for å melde deg på ditt første fag. + Oppdater Programdetaljer + Tid + Denne widgeten oppdateres når data blir tilgjengelige. + Vi kunne ikke laste inn dette innholdet.\nPrøv på nytt. + Oppdater + timer i + timer i faget ditt + alle fag + Ferdighetshøydepunkter + Ingen data ennå + Denne widgeten oppdateres når data blir tilgjengelige. + Vi kunne ikke laste inn dette innholdet. + Prøv på nytt. + Oppdater + Nybegynner + Dreven + Avanserte + Ekspert Gratulerer! Du har fullført faget ditt. Se fremgangen og poengsummene dine på Lær-siden. + Aktiviteter + fullført + Denne widgeten oppdateres når data blir tilgjengelige. + Vi kunne ikke laste inn dette innholdet.\nPrøv på nytt. + Oppdater + Ferdigheter + opptjent + Denne widgeten oppdateres når data blir tilgjengelige. + Oppdater + Vi kunne ikke laste inn dette innholdet.\nPrøv på nytt. Velg fag Lukk Utvidet @@ -379,4 +405,22 @@ Ikke fullført Låst Ikke valgt + Fra %1$s + Gå til beskjed + Vi kunne ikke laste inn dette innholdet.\nPrøv på nytt. + %1$d av %2$d + Forrige beskjed + Neste beskjed + Program + Velkommen til %1$s! Vis programmet ditt for å melde deg på ditt første fag. + Du er for øyeblikket ikke påmeldt i et fag. + %1$s widget laster inn + Fag + %1$d fullført + %1$d opptjent + %1$d timer i %2$s + %1$d timer i faget ditt + Oppdater + Neste objekt + Forrige artikkel diff --git a/libs/horizon/src/main/res/values-b+sv+SE+instk12/strings.xml b/libs/horizon/src/main/res/values-b+sv+SE+instk12/strings.xml index 0fb3fa5c3e..3dba030e63 100644 --- a/libs/horizon/src/main/res/values-b+sv+SE+instk12/strings.xml +++ b/libs/horizon/src/main/res/values-b+sv+SE+instk12/strings.xml @@ -365,10 +365,36 @@ %1$d\%% slutförd Det gick inte att läsa in detta innehåll. Försök igen. - Försök igen - Välkommen! Visa ditt program för att registrera dig i din första kurs. + Uppdatera Programinformation - Grattis! Du har slutfört din kurs. Visa dina framsteg och poäng på sidan Lär. + Tid + Den här widgeten kommer att uppdateras när data blir tillgängliga. + Det gick inte att läsa in detta innehåll.\nFörsök igen. + Uppdatera + timmar i + timmar i din kurs + alla kurser + Kompetenshöjdpunkter + Inga data än + Den här widgeten kommer att uppdateras när data blir tillgängliga. + Det gick inte att läsa in detta innehåll. + Försök igen. + Uppdatera + Nybörjare + Kunnig + Avancerat + Expert + Grattis! Du har slutfört kursen. Visa dina framsteg och poäng på sidan Lär. + Aktiviteter + färdig + Den här widgeten kommer att uppdateras när data blir tillgängliga. + Det gick inte att läsa in detta innehåll.\nFörsök igen. + Uppdatera + Färdigheter + intjänade + Den här widgeten kommer att uppdateras när data blir tillgängliga. + Uppdatera + Det gick inte att läsa in detta innehåll.\nFörsök igen. Välj kurs Stäng Förstorad @@ -379,4 +405,22 @@ Inte slutförd Låst Omarkerade + Från %1$s + Gå till meddelande + Det gick inte att läsa in detta innehåll.\nFörsök igen. + %1$d av %2$d + Tidigare meddelande + Nästa meddelande + Program + Välkommen till %1$s! Visa ditt program för att registrera dig i din första kurs. + Du är för närvarande inte registrerad i en kurs. + %1$s-widgeten läses in + Kurser + %1$d slutförd + %1$d intjänad + %1$d timmar i %2$s + %1$d timmar i din kurs + Uppdatera + Nästa objekt + Föregående objekt diff --git a/libs/horizon/src/main/res/values-b+zh+HK/strings.xml b/libs/horizon/src/main/res/values-b+zh+HK/strings.xml index 0473892cef..acaf85c950 100644 --- a/libs/horizon/src/main/res/values-b+zh+HK/strings.xml +++ b/libs/horizon/src/main/res/values-b+zh+HK/strings.xml @@ -359,10 +359,36 @@ 完成 %1$d\%% 我們無法載入此內容。 請重試。 - 重試 - 歡迎!檢視您的方案並註冊您的第一門課程。 + 重新整理 方案詳細資料 + 時間 + 此小工具將在資料可用時更新。 + 我們無法載入此內容。\n請再試一次。 + 重新整理 + 時數 + 您課程中的時數 + 所有課程 + 技能亮點 + 尚無資料 + 此小工具將在資料可用時更新。 + 我們無法載入此內容。 + 請再試一次。 + 重新整理 + 初級 + 中級 + 進階 + 專家 恭喜!您已完成課程。在「學習」頁面上檢視您的進度和分數。 + 活動 + 已完成 + 此小工具將在資料可用時更新。 + 我們無法載入此內容。\n請再試一次。 + 重新整理 + 技能 + 已獲得 + 此小工具將在資料可用時更新。 + 重新整理 + 我們無法載入此內容。\n請再試一次。 選擇課程 關閉 已展開 @@ -373,4 +399,22 @@ 未完成 已鎖定 未選擇 + 來自 %1$s + 前往公告 + 我們無法載入此內容。\n請再試一次。 + %2$d 的 %1$d + 上一個通告 + 下一個通告 + 方案 + 歡迎來到 %1$s!檢視您的方案並註冊您的第一門課程。 + 您目前尚未註冊課程。 + %1$s 小工具正在載入中 + 課程 + 已完成 %1$d + 已獲得 %1$d + %2$s 中的 %1$d 小時 + 您課程中的 %1$d 小時 + 重新整理 + 下一個項目 + 上一個項目 diff --git a/libs/horizon/src/main/res/values-b+zh+Hans/strings.xml b/libs/horizon/src/main/res/values-b+zh+Hans/strings.xml index 8c70a7e1a4..1fd914603d 100644 --- a/libs/horizon/src/main/res/values-b+zh+Hans/strings.xml +++ b/libs/horizon/src/main/res/values-b+zh+Hans/strings.xml @@ -359,10 +359,36 @@ %1$d\%% 完成 无法加载此内容。 请重试。 - 重试 - 欢迎!查看您的计划以注册第一个课程 + 刷新 计划详情 + 时间 + 数据可用后,小工具将更新。 + 无法加载此内容。\n请重试。 + 刷新 + 小时 + 小时课程时间 + 全部课程 + 技能亮点 + 暂无数据 + 数据可用后,小工具将更新。 + 无法加载此内容。 + 请重试。 + 刷新 + 初学者 + 熟练 + 高级 + 专家 祝贺!您已完成课程。请在学习页面上查看进度和评分。 + 活动 + 完成 + 数据可用后,小工具将更新。 + 无法加载此内容。\n请重试。 + 刷新 + 技能 + 已获 + 数据可用后,小工具将更新。 + 刷新 + 无法加载此内容。\n请重试。 选择课程 关闭 已扩展 @@ -373,4 +399,22 @@ 未完成 已锁定 未选中 + 从 %1$s + 前往公告 + 无法加载此内容。\n请重试。 + %1$d,共%2$d + 上一个公告 + 下一个公告 + 项目 + 欢迎参加 %1$s!查看您的计划以注册第一个课程 + 您目前未注册课程。 + %1$s 小工具加载中 + 课程 + %1$d 已完成 + 已获 %1$d + %1$d 小时 %2$s 时间 + %1$d 小时课程时间 + 刷新 + 下一个项目 + 先前的项目 diff --git a/libs/horizon/src/main/res/values-b+zh+Hant/strings.xml b/libs/horizon/src/main/res/values-b+zh+Hant/strings.xml index 0473892cef..acaf85c950 100644 --- a/libs/horizon/src/main/res/values-b+zh+Hant/strings.xml +++ b/libs/horizon/src/main/res/values-b+zh+Hant/strings.xml @@ -359,10 +359,36 @@ 完成 %1$d\%% 我們無法載入此內容。 請重試。 - 重試 - 歡迎!檢視您的方案並註冊您的第一門課程。 + 重新整理 方案詳細資料 + 時間 + 此小工具將在資料可用時更新。 + 我們無法載入此內容。\n請再試一次。 + 重新整理 + 時數 + 您課程中的時數 + 所有課程 + 技能亮點 + 尚無資料 + 此小工具將在資料可用時更新。 + 我們無法載入此內容。 + 請再試一次。 + 重新整理 + 初級 + 中級 + 進階 + 專家 恭喜!您已完成課程。在「學習」頁面上檢視您的進度和分數。 + 活動 + 已完成 + 此小工具將在資料可用時更新。 + 我們無法載入此內容。\n請再試一次。 + 重新整理 + 技能 + 已獲得 + 此小工具將在資料可用時更新。 + 重新整理 + 我們無法載入此內容。\n請再試一次。 選擇課程 關閉 已展開 @@ -373,4 +399,22 @@ 未完成 已鎖定 未選擇 + 來自 %1$s + 前往公告 + 我們無法載入此內容。\n請再試一次。 + %2$d 的 %1$d + 上一個通告 + 下一個通告 + 方案 + 歡迎來到 %1$s!檢視您的方案並註冊您的第一門課程。 + 您目前尚未註冊課程。 + %1$s 小工具正在載入中 + 課程 + 已完成 %1$d + 已獲得 %1$d + %2$s 中的 %1$d 小時 + 您課程中的 %1$d 小時 + 重新整理 + 下一個項目 + 上一個項目 diff --git a/libs/horizon/src/main/res/values-ca/strings.xml b/libs/horizon/src/main/res/values-ca/strings.xml index c621b27d6e..18877126c5 100644 --- a/libs/horizon/src/main/res/values-ca/strings.xml +++ b/libs/horizon/src/main/res/values-ca/strings.xml @@ -365,10 +365,36 @@ %1$d % complet No hem pogut carregar aquest contingut. Torneu a provar-ho. - Torna a provar-ho - Us donem la benvinguda! Consulteu el programa per inscriure-us en la primera assignatura. + Actualitza-ho Informació del programa + Hora + Aquest giny s’actualitzarà quan les dades estiguin disponibles. + No hem pogut carregar aquest contingut.\nTorneu a provar-ho. + Actualitza-ho + hores a + hores a la vostra assignatura + totes les assignatures + Punts destacats de l’habilitat + Encara no hi ha cap dada + Aquest giny s’actualitzarà quan les dades estiguin disponibles. + No hem pogut carregar aquest contingut. + Torneu a provar-ho. + Actualitza-ho + Principiant + Competent + Avançat + Expert Enhorabona! Heu completat l’assignatura. Consulteu el vostre progrés i les puntuacions a la pàgina Aprèn. + Activitats + completades + Aquest giny s’actualitzarà quan les dades estiguin disponibles. + No hem pogut carregar aquest contingut.\nTorneu a provar-ho. + Actualitza-ho + Habilitats + adquirides + Aquest giny s’actualitzarà quan les dades estiguin disponibles. + Actualitza-ho + No hem pogut carregar aquest contingut.\nTorneu a provar-ho. Seleccioneu l\'assignatura Tanca-ho S’ha desplegat @@ -379,4 +405,22 @@ No s\'ha completat S’ha bloquejat S’ha n’anul·lat la selecció + De %1$s + Aneu a l’anunci + No hem pogut carregar aquest contingut.\nTorneu a provar-ho. + %1$d de %2$d + Anunci anterior + Anunci següent + Programa + Us donem la benvinguda a %1$s! Consulteu el programa per inscriure-us en la primera assignatura. + En aquest moment, no esteu inscrit en cap assignatura. + S\'està carregant el giny %1$s + Assignatures + Se n’han completat %1$d + Se n’han adquirit %1$d + %1$d hores a %2$s + %1$d hores a la vostra assignatura + Actualitza-ho + L\'element següent + L\'element anterior diff --git a/libs/horizon/src/main/res/values-cy/strings.xml b/libs/horizon/src/main/res/values-cy/strings.xml index 4f1242b2d1..864dc3e9be 100644 --- a/libs/horizon/src/main/res/values-cy/strings.xml +++ b/libs/horizon/src/main/res/values-cy/strings.xml @@ -365,10 +365,36 @@ %1$d\%% wedi cwblhau Wedi methu llwytho’r cynnwys hwn. Rhowch gynnig arall arni. - Ailgynnig - Croeso! Edrychwch ar eich rhaglen i gofrestru ar gyfer eich cwrs cyntaf. + Adnewyddu Manylion y rhaglen + Amser + Bydd y teclyn hwn yn adnewyddu pan fydd data ar gael. + Doedd dim modd llwytho’r cynnwys hwn.\nRhowch gynnig arall arni. + Adnewyddu + awr yn + awr yn eich cwrs + pob cwrs + Uchafbwyntiau Sgiliau + Dim data eto + Bydd y teclyn hwn yn adnewyddu pan fydd data ar gael. + Doedd dim modd llwytho\'r cynnwys hwn. + Rhowch gynnig arall arni. + Adnewyddu + Dechreuwr + Medrus + Uwch + Arbenigwr Llongyfarchiadau! Rydych chi wedi cwblhau eich cwrs. Edrychwch ar eich cynnydd a’ch sgoriau ar y dudalen Dysgu. + Gweithgareddau + wedi cwblhau + Bydd y teclyn hwn yn adnewyddu pan fydd data ar gael. + Doedd dim modd llwytho’r cynnwys hwn.\nRhowch gynnig arall arni. + Adnewyddu + Sgiliau + wedi ennill + Bydd y teclyn hwn yn adnewyddu pan fydd data ar gael. + Adnewyddu + Doedd dim modd llwytho’r cynnwys hwn.\nRhowch gynnig arall arni. Dewis Cwrs Cau Wedi ehangu @@ -379,4 +405,22 @@ Heb gwblhau Wedi cloi Heb ddewis + Gan %1$s + Mynd i\'r cyhoeddiad + Doedd dim modd llwytho’r cynnwys hwn.\nRhowch gynnig arall arni. + %1$d o %2$d + Cyhoeddiad blaenorol + Cyhoeddiad nesaf + Rhaglen + Croeso i %1$s! Edrychwch ar eich rhaglen i gofrestru ar gyfer eich cwrs cyntaf. + Dydych chi ddim wedi ymrestru ar gwrs ar hyn o bryd. + %1$s o’r teclyn yn llwytho + Cyrsiau + Wedi cwblhau %1$d + Wedi ennill %1$d + %1$d awr yn %2$s + %1$d awr yn eich cwrs + Adnewyddu + Eitem nesaf + Eitem flaenorol diff --git a/libs/horizon/src/main/res/values-da/strings.xml b/libs/horizon/src/main/res/values-da/strings.xml index a4215b1c38..1790e6d7cb 100644 --- a/libs/horizon/src/main/res/values-da/strings.xml +++ b/libs/horizon/src/main/res/values-da/strings.xml @@ -365,10 +365,36 @@ %1$d\%% afsluttet Vi kunne ikke indlæse dette indhold. Prøv igen. - Prøv igen - Velkommen! Se dit program for at tilmelde dig dit første fag. + Opdater Programdetaljer + Tidspunkt + Denne widget opdateres, når data bliver tilgængelige. + Vi kunne ikke indlæse dette indhold.\nPrøv igen. + Opdater + timer i + timer i dit fag + alle fag + Færdighedshøjdepunkter + Ingen data endnu + Denne widget opdateres, når data bliver tilgængelige. + Vi kunne ikke indlæse dette indhold. + Prøv igen. + Opdater + Nybegynder + Kompetent + Avanceret + Ekspert Tillykke! Du har afsluttet dit fag. Se dine fremskridt og resultater på siden Lær. + Aktiviteter + fuldført + Denne widget opdateres, når data bliver tilgængelige. + Vi kunne ikke indlæse dette indhold.\nPrøv igen. + Opdater + Færdigheder + optjent + Denne widget opdateres, når data bliver tilgængelige. + Opdater + Vi kunne ikke indlæse dette indhold.\nPrøv igen. Vælg fag Luk Udvidet @@ -379,4 +405,22 @@ Ikke afsluttet Låst Ikke valgt + Fra %1$s + Gå til besked + Vi kunne ikke indlæse dette indhold.\nPrøv igen. + %1$d af %2$d + Tidligere besked + Næste besked + Program + Velkommen til %1$s! Se dit program for at tilmelde dig dit første fag. + Du er ikke tilmeldt et fag i øjeblikket. + Widgeten %1$s indlæses + Fag + %1$d afsluttet + %1$d optjent + %1$d timer i %2$s + %1$d timer i dit fag + Opdater + Næste element + Forrige element diff --git a/libs/horizon/src/main/res/values-de/strings.xml b/libs/horizon/src/main/res/values-de/strings.xml index 488b0df1e2..3235835159 100644 --- a/libs/horizon/src/main/res/values-de/strings.xml +++ b/libs/horizon/src/main/res/values-de/strings.xml @@ -365,10 +365,36 @@ %1$d\%% vollständig Wir können diesen Inhalt nicht laden. Bitte versuchen Sie es erneut. - Erneut versuchen - Willkommen! Sehen Sie sich Ihr Programm an, um sich für Ihren ersten Kurs einzuschreiben. + Aktualisieren Programmdetails + Zeit + Dieses Widget wird aktualisiert, sobald Daten verfügbar sind. + Wir können diesen Inhalt nicht laden.\nBitte versuchen Sie es erneut. + Aktualisieren + Stunden in + Stunden in Ihrem Kurs + alle Kurse + Fähigkeits-Highlights + Noch keine Daten + Dieses Widget wird aktualisiert, sobald Daten verfügbar sind. + Wir können diesen Inhalt nicht laden. + Bitte versuchen Sie es erneut. + Aktualisieren + Anfänger + Leicht fortgeschritten + Fortgeschritten + Experte Glückwunsch! Sie haben den Kurs abgeschlossen. Sehen Sie sich Ihren Fortschritt und Ihre Punktzahl auf der Seite „Lernen“ an. + Aktivitäten + fertiggestellt + Dieses Widget wird aktualisiert, sobald Daten verfügbar sind. + Wir können diesen Inhalt nicht laden.\nBitte versuchen Sie es erneut. + Aktualisieren + Fertigkeiten + verdient + Dieses Widget wird aktualisiert, sobald Daten verfügbar sind. + Aktualisieren + Wir können diesen Inhalt nicht laden.\nBitte versuchen Sie es erneut. Kurs auswählen Schließen Erweitert @@ -379,4 +405,22 @@ Nicht abgeschlossen Gesperrt Nicht ausgewählt + Von %1$s + Zu Ankündigung gehen + Wir können diesen Inhalt nicht laden.\nBitte versuchen Sie es erneut. + %1$d von %2$d + Vorherige Ankündigung + Nächste Ankündigung + Programm + Willkommen bei %1$s! Sehen Sie sich Ihr Programm an, um sich für Ihren ersten Kurs einzuschreiben. + Sie sind derzeit nicht in einem Kurs eingeschrieben. + Widget %1$s wird geladen + Kurse + %1$d abgeschlossen + %1$d verdient + %1$d Stunden in %2$s + %1$d Stunden in Ihrem Kurs + Aktualisieren + Nächstes Element + Vorheriges Element diff --git a/libs/horizon/src/main/res/values-en-rAU/strings.xml b/libs/horizon/src/main/res/values-en-rAU/strings.xml index d62ad54c51..fdbc01211c 100644 --- a/libs/horizon/src/main/res/values-en-rAU/strings.xml +++ b/libs/horizon/src/main/res/values-en-rAU/strings.xml @@ -365,10 +365,36 @@ %1$d\%% complete We weren’t able to load this content. Please try again. - Retry - Welcome! View your program to enrol in your first course. + Refresh Program details - Congrats! You’ve completed your course. View your progress and scores on the Learn page. + Time + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + hours in + hours in your course + all courses + Skill Highlights + No data yet + This widget will update once data becomes available. + We weren\'t able to load this content. + Please try again. + Refresh + Beginner + Proficient + Advanced + Expert + Congrats! You\'ve completed your course. View your progress and scores on the Learn page. + Activities + completed + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + Skills + earned + This widget will update once data becomes available. + Refresh + We weren\'t able to load this content.\nPlease try again. Select Course Close Expanded @@ -379,4 +405,22 @@ Not completed Locked Unselected + From %1$s + Go to announcement + We weren\'t able to load this content.\nPlease try again. + %1$d of %2$d + Previous announcement + Next announcement + Program + Welcome to %1$s! View your program to enrol in your first course. + You aren’t currently enrolled in a course. + %1$s widget is loading + Courses + %1$d completed + %1$d earned + %1$d hours in %2$s + %1$d hours in your course + Refresh + Next item + Previous item diff --git a/libs/horizon/src/main/res/values-en-rCY/strings.xml b/libs/horizon/src/main/res/values-en-rCY/strings.xml index ff4565981d..4052f08ca1 100644 --- a/libs/horizon/src/main/res/values-en-rCY/strings.xml +++ b/libs/horizon/src/main/res/values-en-rCY/strings.xml @@ -365,10 +365,36 @@ %1$d\%% complete We weren’t able to load this content. Please try again. - Retry - Welcome! View your program to enrol in your first module. + Refresh Program details - Congrats! You’ve completed your module. View your progress and scores on the Learn page. + Time + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + hours in + hours in your module + all modules + Skill Highlights + No data yet + This widget will update once data becomes available. + We weren\'t able to load this content. + Please try again. + Refresh + Beginner + Proficient + Advanced + Expert + Congrats! You\'ve completed your module. View your progress and scores on the Learn page. + Activities + completed + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + Skills + earned + This widget will update once data becomes available. + Refresh + We weren\'t able to load this content.\nPlease try again. Select Module Close Expanded @@ -379,4 +405,22 @@ Not completed Locked Unselected + From %1$s + Go to announcement + We weren\'t able to load this content.\nPlease try again. + %1$d of %2$d + Previous announcement + Next announcement + Programme + Welcome to %1$s! View your program to enrol in your first module. + You aren’t currently enrolled in a module. + %1$s widget is loading + Modules + %1$d completed + %1$d earned + %1$d hours in %2$s + %1$d hours in your module + Refresh + Next item + Previous item diff --git a/libs/horizon/src/main/res/values-en-rGB/strings.xml b/libs/horizon/src/main/res/values-en-rGB/strings.xml index b726e0bd60..d0074f92d1 100644 --- a/libs/horizon/src/main/res/values-en-rGB/strings.xml +++ b/libs/horizon/src/main/res/values-en-rGB/strings.xml @@ -365,10 +365,36 @@ %1$d\%% complete We weren’t able to load this content. Please try again. - Retry - Welcome! View your program to enrol in your first course. + Refresh Program details - Congrats! You’ve completed your course. View your progress and scores on the Learn page. + Time + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + hours in + hours in your course + all courses + Skill Highlights + No data yet + This widget will update once data becomes available. + We weren\'t able to load this content. + Please try again. + Refresh + Beginner + Proficient + Advanced + Expert + Congrats! You\'ve completed your course. View your progress and scores on the Learn page. + Activities + completed + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + Skills + earned + This widget will update once data becomes available. + Refresh + We weren\'t able to load this content.\nPlease try again. Select Course Close Expanded @@ -379,4 +405,22 @@ Not completed Locked Unselected + From %1$s + Go to announcement + We weren\'t able to load this content.\nPlease try again. + %1$d of %2$d + Previous announcement + Next announcement + Programme + Welcome to %1$s! View your program to enrol in your first course. + You aren’t currently enrolled in a course. + %1$s widget is loading + Courses + %1$d completed + %1$d earned + %1$d hours in %2$s + %1$d hours in your course + Refresh + Next item + Previous item diff --git a/libs/horizon/src/main/res/values-en/strings.xml b/libs/horizon/src/main/res/values-en/strings.xml index 16936cde6f..03d2d0a0fa 100644 --- a/libs/horizon/src/main/res/values-en/strings.xml +++ b/libs/horizon/src/main/res/values-en/strings.xml @@ -364,10 +364,36 @@ %1$d\%% complete We weren’t able to load this content. Please try again. - Retry - Welcome! View your program to enroll in your first course. + Refresh Program details - Congrats! You’ve completed your course. View your progress and scores on the Learn page. + Time + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + hours in + hours in your course + all courses + Skill Highlights + No data yet + This widget will update once data becomes available. + We weren\'t able to load this content. + Please try again. + Refresh + Beginner + Proficient + Advanced + Expert + Congrats! You\'ve completed your course. View your progress and scores on the Learn page. + Activities + completed + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh + Skills + earned + This widget will update once data becomes available. + Refresh + We weren\'t able to load this content.\nPlease try again. Select Course Close Expanded @@ -378,4 +404,22 @@ Not completed Locked Unselected + From %1$s + Go to announcement + We weren\'t able to load this content.\nPlease try again. + %1$d of %2$d + Previous announcement + Next announcement + Program + Welcome to %1$s! View your program to enroll in your first course. + You aren’t currently enrolled in a course. + %1$s widget is loading + Courses + %1$d completed + %1$d earned + %1$d hours in %2$s + %1$d hours in your course + Refresh + Next item + Previous item \ No newline at end of file diff --git a/libs/horizon/src/main/res/values-es-rES/strings.xml b/libs/horizon/src/main/res/values-es-rES/strings.xml index 8bdf410869..ed38977ee2 100644 --- a/libs/horizon/src/main/res/values-es-rES/strings.xml +++ b/libs/horizon/src/main/res/values-es-rES/strings.xml @@ -365,10 +365,36 @@ %1$d\%% completado No hemos podido cargar este contenido. Inténtalo de nuevo. - Reintentar - ¡Te damos la bienvenida! Consulta tu programa para inscribirte en tu primera asignatura. + Actualizar Detalles del programa + Tiempo + Este widget se actualizará cuando haya datos disponibles. + No hemos podido cargar este contenido.\nInténtalo de nuevo. + Actualizar + horas en + horas en tu asignatura + todas las asignaturas + Aspectos destacados de habilidades + Aún no hay datos + Este widget se actualizará cuando haya datos disponibles. + No hemos podido cargar este contenido. + Inténtalo de nuevo. + Actualizar + Principiante + Competente + Avanzado + Experto ¡Enhorabuena! Has completado tu asignatura. Consulta tus progresos y puntuaciones en la página Aprendizaje. + Actividades + completado + Este widget se actualizará cuando haya datos disponibles. + No hemos podido cargar este contenido.\nInténtalo de nuevo. + Actualizar + Habilidades + adquiridas + Este widget se actualizará cuando haya datos disponibles. + Actualizar + No hemos podido cargar este contenido.\nInténtalo de nuevo. Seleccionar asignatura Cerrar Expandido @@ -379,4 +405,22 @@ No completado Bloqueado Sin seleccionar + De %1$s + Ir al anuncio + No hemos podido cargar este contenido.\nInténtalo de nuevo. + %1$d de %2$d + Anuncio anterior + Próximo anuncio + Programa + ¡Te damos la bienvenida a %1$s! Consulta tu programa para inscribirte en tu primera asignatura. + Actualmente no estás inscrito en ninguna asignatura. + Se está cargando el widget %1$s + Asignaturas + %1$d completado + %1$d adquirida + %1$d horas en %2$s + %1$d horas en tu asignatura + Actualizar + Siguiente ítem + Ítem anterior diff --git a/libs/horizon/src/main/res/values-es/strings.xml b/libs/horizon/src/main/res/values-es/strings.xml index c17c6f6363..efe9f9195f 100644 --- a/libs/horizon/src/main/res/values-es/strings.xml +++ b/libs/horizon/src/main/res/values-es/strings.xml @@ -365,10 +365,36 @@ %1$d\%% completo No pudimos cargar este contenido. Inténtelo de nuevo. - Reintentar - ¡Bienvenido! Consulte su programa para inscribirse en el primer curso. + Actualizar Detalles del programa + Hora + Este widget se actualizará cuando los datos estén disponibles. + No pudimos cargar este contenido.\nInténtelo de nuevo. + Actualizar + horas en + horas en su curso + todos los cursos + Habilidades destacadas + Aún no hay datos disponibles + Este widget se actualizará cuando los datos estén disponibles. + No pudimos cargar este contenido. + Inténtelo de nuevo. + Actualizar + Principiante + Competente + Avanzado + Experto ¡Felicidades! Completó el curso. Vea su progreso y sus puntajes en la página Aprender. + Actividades + completadas + Este widget se actualizará cuando los datos estén disponibles. + No pudimos cargar este contenido.\nInténtelo de nuevo. + Actualizar + Habilidades + obtenidas + Este widget se actualizará cuando los datos estén disponibles. + Actualizar + No pudimos cargar este contenido.\nInténtelo de nuevo. Seleccionar curso Cerrar Expandido @@ -379,4 +405,22 @@ Sin completar Bloqueado No seleccionado + De %1$s + Ir al anuncio + No pudimos cargar este contenido.\nInténtelo de nuevo. + %1$d de %2$d + Anuncio anterior + Próximo anuncio + Programa + ¡Le damos la bienvenida a %1$s! Consulte su programa para inscribirse en el primer curso. + En este momento, no está inscrito en ningún curso. + El widget %1$s se está cargando + Cursos + %1$d completado + %1$d obtenido + %1$d horas en %2$s + %1$d horas en su curso + Actualizar + Siguiente ítem + Ítem anterior diff --git a/libs/horizon/src/main/res/values-fi/strings.xml b/libs/horizon/src/main/res/values-fi/strings.xml index ae7fe93cb6..4d699d916c 100644 --- a/libs/horizon/src/main/res/values-fi/strings.xml +++ b/libs/horizon/src/main/res/values-fi/strings.xml @@ -365,10 +365,36 @@ %1$d\%% valmis Emme voineet ladata tätä sisältöä. Yritä uudelleen. - Yritä uudelleen - Tervetuloa! Näytä ohjelmasi ilmoittautuaksesi ensimmäiselle kurssillesi. + Päivitä Ohjelman tiedot + Aika + Tämä widget päivittyy, kun tiedot ovat saatavilla. + Emme voineet ladata tätä sisältöä.\nYritä uudelleen. + Päivitä + tunnit + tuntia kurssillasi + kaikki kurssit + Taidon kohokohdat + Ei vielä tietoja + Tämä widget päivittyy, kun tiedot ovat saatavilla. + Emme voineet ladata tätä sisältöä. + Yritä uudelleen. + Päivitä + Aloittelija + Taitava + Edistyneet + Asiantuntija Onnittelut! Olet suorittanut kurssisi. Katso edistymistäsi ja pisteitäsi Opi-sivulla. + Toimet + valmis + Tämä widget päivittyy, kun tiedot ovat saatavilla. + Emme voineet ladata tätä sisältöä.\nYritä uudelleen. + Päivitä + Taidot + ansaittu + Tämä widget päivittyy, kun tiedot ovat saatavilla. + Päivitä + Emme voineet ladata tätä sisältöä.\nYritä uudelleen. Valitse kurssi Sulje Laajennettu @@ -379,4 +405,22 @@ Ei valmis Lukittu Valinta poistettu + Lähettäjältä %1$s + Siirry ilmoituksiin + Emme voineet ladata tätä sisältöä.\nYritä uudelleen. + %1$d/%2$d + Edellinen ilmoitus + Seuraava ilmoitus + Ohjelma + Tervetuloa %1$s! Näytä ohjelmasi ilmoittautuaksesi ensimmäiselle kurssillesi. + Kaikki tekstikentän sisältö poistetaan lähetyksen jälkeen. + %1$s widget latautuu + Kurssit + %1$d suoritettu + %1$d ansaittu + %1$d tuntia kohteessa %2$s + %1$d tuntia kurssillasi + Päivitä + Seuraava kohde + Edellinen kohde diff --git a/libs/horizon/src/main/res/values-fr-rCA/strings.xml b/libs/horizon/src/main/res/values-fr-rCA/strings.xml index 1a2191fcd3..bc8dbc81e7 100644 --- a/libs/horizon/src/main/res/values-fr-rCA/strings.xml +++ b/libs/horizon/src/main/res/values-fr-rCA/strings.xml @@ -365,10 +365,36 @@ %1$d\%% terminé Nous n\'avons pas pu charger ce contenu. Veuillez réessayer. - Réessayer - Bienvenue! Consultez votre programme pour vous inscrire à votre premier cours. + Actualiser Détails du programme + Heure + Ce widget sera mis à jour dès que les données seront disponibles. + Nous n’avons pas pu charger ce contenu.\nVeuillez réessayer. + Actualiser + heures en + heures dans votre cours + tous les cours + Faits marquants des compétences + Aucunes données disponibles. + Ce widget sera mis à jour dès que les données seront disponibles. + Nous n’avons pas pu charger ce contenu. + Veuillez réessayer. + Actualiser + Débutant + Habile + Avancé + Expert Félicitations! Vous avez terminé votre formation. Consultez vos progrès et vos scores sur la page Apprendre. + Activités + terminé + Ce widget sera mis à jour dès que les données seront disponibles. + Nous n\'avons pas pu charger ce contenu.\nVeuillez réessayer. + Actualiser + Compétences + obtenu + Ce widget sera mis à jour dès que les données seront disponibles. + Actualiser + Nous n’avons pas pu charger ce contenu.\nVeuillez réessayer. Sélectionner un cours Fermer Développé(e) @@ -379,4 +405,22 @@ Non terminé Verrouillé Non sélectionné + De %1$s + Aller à l’annonce + Nous n’avons pas pu charger ce contenu.\nVeuillez réessayer. + %1$d sur %2$d + Annonce précédente + Prochaine communication + Programme + Bienvenue sur %1$s! Consultez votre programme pour vous inscrire à votre premier cours. + Vous n’avez pas encore enregistré votre inscription à un cours. + Le widget %1$s se charge + Cours + %1$d terminé + %1$d obtenu + %1$d heures en %2$s + %1$d heures dans votre cours + Actualiser + Élément suivant + Élément précédent diff --git a/libs/horizon/src/main/res/values-fr/strings.xml b/libs/horizon/src/main/res/values-fr/strings.xml index 5bb1ed6f88..fef8e5e74b 100644 --- a/libs/horizon/src/main/res/values-fr/strings.xml +++ b/libs/horizon/src/main/res/values-fr/strings.xml @@ -365,10 +365,36 @@ %1$d\%% terminé Nous n’avons pas pu charger ce contenu. Veuillez réessayer. - Réessayer - Bienvenue ! Affichez votre programme pour vous inscrire à votre premier cours. + Actualiser Détails du programme + Temps + Le widget sera mis à jour dès que des données seront disponibles. + Nous n\’avons pas pu charger ce contenu.\nVeuillez réessayer. + Actualiser + heures passées dans + heures passées dans votre cours + tous les cours + Aptitudes à la une + Aucune donnée pour le moment + Le widget sera mis à jour dès que des données seront disponibles. + Nous n\’avons pas pu charger ce contenu. + Veuillez réessayer. + Actualiser + Débutant + Compétent + Avancé + Expert Félicitations ! Vous avez terminé votre cours. Affichez vos scores et progrès sur la page Apprentissage. + Activités + terminé + Le widget sera mis à jour dès que des données seront disponibles. + Nous n\’avons pas pu charger ce contenu.\nVeuillez réessayer. + Actualiser + Aptitudes + acquis + Le widget sera mis à jour dès que des données seront disponibles. + Actualiser + Nous n\’avons pas pu charger ce contenu.\nVeuillez réessayer. Sélectionner un cours Fermer Développé(e) @@ -379,4 +405,22 @@ Non terminé Verrouillé Non sélectionné + De %1$s + Accéder à l’annonce + Nous n\’avons pas pu charger ce contenu.\nVeuillez réessayer. + %1$d sur %2$d + Annonce précédente + Annonce suivante + Programme + Bienvenue dans %1$s ! Affichez votre programme pour vous inscrire à votre premier cours. + Vous n’êtes inscrit(e) à aucun cours actuellement. + Le widget %1$s est en cours de chargement + Cours + %1$d terminé + %1$d acquis + %1$d heures passées dans %2$s + %1$d heures passées dans votre cours + Actualiser + Élément suivant + Élément précédent diff --git a/libs/horizon/src/main/res/values-ga/strings.xml b/libs/horizon/src/main/res/values-ga/strings.xml index b5154b34a4..c688da904e 100644 --- a/libs/horizon/src/main/res/values-ga/strings.xml +++ b/libs/horizon/src/main/res/values-ga/strings.xml @@ -365,10 +365,36 @@ %1$d\%% críochnaithe Ní raibh muid in ann an t-ábhar seo a lódáil. Bain triail as arís, le do thoil. - Bain triail eile as - Fáilte romhat! Féach ar do chlár chun clárú i do chéad chúrsa. + Athnuaigh Sonraí an chláir + Am + Uasdátófar an giuirléid seo a luaithe a bheidh sonraí ar fáil. + Ní raibh muid in ann an t-ábhar seo a lódáil.\nDéan iarracht arís. + Athnuaigh + uaireanta in + uaireanta i do chúrsa + gach cúrsa + Buaicphointí Scileanna + Gan aon sonraí fós + Uasdátófar an giuirléid seo a luaithe a bheidh sonraí ar fáil. + Ní raibh muid in ann an t-ábhar seo a luchtú. + Bain triail as arís, le do thoil. + Athnuaigh + Tosaitheoirí + Oiliúnach + Casta + Saineolach Comhghairdeas! Tá do chúrsa críochnaithe agat. Féach ar do dhul chun cinn agus do scóir ar an leathanach Foghlama. + Gníomhaíochtaí + críochnaithe + Uasdátófar an giuirléid seo a luaithe a bheidh sonraí ar fáil. + Ní raibh muid in ann an t-ábhar seo a lódáil.\nDéan iarracht arís. + Athnuaigh + Scileanna + tuillte + Uasdátófar an giuirléid seo a luaithe a bheidh sonraí ar fáil. + Athnuaigh + Ní raibh muid in ann an t-ábhar seo a lódáil.\nDéan iarracht arís. Roghnaigh Cúrsa Dún Leathnaithe @@ -379,4 +405,22 @@ Níl sé críochnaithe Faoi ghlas Gan roghnú + Ó %1$s + Téigh go dtí an fógra + Ní raibh muid in ann an t-ábhar seo a lódáil.\nDéan iarracht arís. + %1$d de %2$d + Fógra roimhe seo + An chéad fhógra eile + Clár + Fáilte go %1$s! Féach ar do chlár chun clárú i do chéad chúrsa. + Níl tú rollaithe i gcúrsa faoi láthair. + tá an %1$s giuirléid á lódáil + Cúrsaí + %1$d críochnaithe + %1$d tuillte + %1$d uair in %2$s + %1$d uair i do chúrsa + Athnuaigh + An chéad mhír eile + An mhír roimhe seo diff --git a/libs/horizon/src/main/res/values-hi/strings.xml b/libs/horizon/src/main/res/values-hi/strings.xml index 4371ea3140..ca70593ef5 100644 --- a/libs/horizon/src/main/res/values-hi/strings.xml +++ b/libs/horizon/src/main/res/values-hi/strings.xml @@ -365,10 +365,36 @@ %1$d\%% पूरा हुआ हम यह सामग्री लोड नहीं कर पाए। कृपया फिर से कोशिश करें। - फिर से कोशिश करें - आपका स्वागत है! अपने पहले पाठ्यक्रम में नामांकन के लिए अपना कार्यक्रम देखें। + रीफ़्रेश करें कार्यक्रम विवरण + समय + डेटा उपलब्ध होने पर यह विजेट अपडेट हो जाएगा। + हम यह सामग्री लोड नहीं कर पाए।\nकृपया फिर से कोशिश करें। + रीफ़्रेश करें + में घंटे + आपके पाठ्यक्रम में घंटे + सभी पाठ्यक्रम + कौशल हाइलाइट्स + अभी तक कोई डेटा नहीं + डेटा उपलब्ध होने पर यह विजेट अपडेट हो जाएगा। + हम यह सामग्री लोड नहीं कर पाए। + कृपया फिर से कोशिश करें। + रीफ़्रेश करें + नौसिखिया + प्रवीण + उन्नत + विशेषज्ञ बधाई हो! आपने अपना पाठ्यक्रम पूरा कर लिया है। \'सीखें\' पेज पर अपनी प्रगति और देखें। + गतिविधियां + पूरा हुआ + डेटा उपलब्ध होने पर यह विजेट अपडेट हो जाएगा। + हम यह सामग्री लोड नहीं कर पाए।\nकृपया फिर से कोशिश करें। + रीफ़्रेश करें + कौशल + अर्जित किया + डेटा उपलब्ध होने पर यह विजेट अपडेट हो जाएगा। + रीफ़्रेश करें + हम यह सामग्री लोड नहीं कर पाए।\nकृपया फिर से कोशिश करें। पाठ्यक्रम चुनें बंद करें बढ़ाया गया @@ -379,4 +405,22 @@ पूरा नहीं हुआ लॉक किया गया अचयनित + %1$s की ओर से + घोषणा पर जाएं + हम यह सामग्री लोड नहीं कर पाए।\nकृपया फिर से कोशिश करें। + %2$dमें से %1$d + पिछली घोषणा + अगली घोषणा + कार्यक्रम + %1$s में आपका स्वागत है! अपने पहले पाठ्यक्रम में नामांकन के लिए अपना कार्यक्रम देखें। + आप वर्तमान में किसी पाठ्यक्रम में नामांकित नहीं हैं। + %1$s विजेट लोड हो रहा है + पाठ्यक्रम + %1$d पूरा हुआ + %1$d अर्जित + %2$s में %1$d घंटे + आपके पाठ्यक्रम में %1$d घंटे + रीफ़्रेश करें + अगली आइटम + पिछली आइटम diff --git a/libs/horizon/src/main/res/values-ht/strings.xml b/libs/horizon/src/main/res/values-ht/strings.xml index f51fc5917b..639ed0e765 100644 --- a/libs/horizon/src/main/res/values-ht/strings.xml +++ b/libs/horizon/src/main/res/values-ht/strings.xml @@ -365,10 +365,36 @@ %1$d\%% konplete Nou pa t kapab chaje kontni sa a. Tanpri eseye ankò. - Re eseye - Bienveni! Konsilte pwogram ou an pou w ka enskri nan premye kou w. + Aktyalize Detay pwogram - Konpliman! Ou fini kou w la. Gade pwogrè ou ak rezilta ou sou paj Aprantisaj la. + Tan + Widget sa a ap aktyalize lè done yo disponib. + Nou pa t kapab chaje kontni sa a.\nTanpri eseye ankò. + Aktyalize + èdtan nan + èdtan nan kou nou an + tout kou yo + Konpetans Enpòtan yo + Pa gen done pou kounye a + Widget sa a ap aktyalize lè done yo disponib. + Nou pa t kapab chaje kontni sa a. + Tanpri eseye ankò. + Aktyalize + Debitan + Konpetan + Avanse + Ekspè + Konpliman! Ou konplete kou w la. Gade pwogrè ou ak rezilta ou sou paj Aprantisaj la. + Aktivite + konplete + Widget sa a ap aktyalize lè done yo disponib. + Nou pa t kapab chaje kontni sa a.\nTanpri eseye ankò. + Aktyalize + Konpetans + resevwa + Widget sa a ap aktyalize lè done yo disponib. + Aktyalize + Nou pa t kapab chaje kontni sa a.\nTanpri eseye ankò. Seleksyone Kou Fèmen Elaji @@ -379,4 +405,22 @@ Poko fini Bloke Pa seleksyone + De %1$s + Ale nan anons la + Nou pa t kapab chaje kontni sa a.\nTanpri eseye ankò. + %1$d de %2$d + Ansyen anons + Pwochen anons + Pwogram + Byenvini nan %1$s! Konsilte pwogram ou an pou w ka enskri nan premye kou w. + Pou kounye a ou pa enskri nan kou. + %1$s widget ap chaje + Kou + %1$d fini + %1$d resevwa + %1$d èdtan nan %2$s + %1$d èdtan nan kou w la + Aktyalize + Pwochen atik + Atik pase diff --git a/libs/horizon/src/main/res/values-id/strings.xml b/libs/horizon/src/main/res/values-id/strings.xml index 14928a1f50..1a72567603 100644 --- a/libs/horizon/src/main/res/values-id/strings.xml +++ b/libs/horizon/src/main/res/values-id/strings.xml @@ -365,10 +365,36 @@ %1$d\%% selesai Kami tidak dapat memuat konten ini. Silakan coba lagi. - Coba lagi - Selamat datang! Lihat program Anda untuk mendaftar kursus pertama Anda. + Muat Ulang Detail program + Waktu + Widget ini akan diperbarui setelah data tersedia. + Kami tidak dapat memuat konten ini.\nSilakan coba lagi. + Muat Ulang + jam dalam + jam dalam kursus Anda + semua kursus + Sorotan skill + Belum ada data + Widget ini akan diperbarui setelah data tersedia. + Kami tidak dapat memuat konten ini. + Silakan coba lagi. + Muat Ulang + Pemula + Mahir + Tingkat Lanjut + Ahli Selamat! Anda telah menyelesaikan kursus Anda. Lihat kemajuan dan skor Anda di halaman Belajar. + Aktivitas + selesai + Widget ini akan diperbarui setelah data tersedia. + Kami tidak dapat memuat konten ini.\nSilakan coba lagi. + Muat Ulang + Keahlian + mendapat + Widget ini akan diperbarui setelah data tersedia. + Muat Ulang + Kami tidak dapat memuat konten ini.\nSilakan coba lagi. Pilih Kursus Tutup Diperbesar @@ -379,4 +405,22 @@ Belum selesai Terkunci Tidak dipilih + Dari %1$s + Pergi Ke pengumuman + Kami tidak dapat memuat konten ini.\nSilakan coba lagi. + %1$d dari %2$d + Pengumuman sebelumnya + Pengumuman selanjutnya + Program + Selamat datang di %1$s! Lihat program Anda untuk mendaftar kursus pertama Anda. + Anda saat ini tidak terdaftar dalam suatu kursus. + Widget %1$s sedang dimuat + Kursus + %1$d selesai + %1$d mendapat + %1$d jam dalam %2$s + %1$d jam dalam kursus Anda + Muat Ulang + Item Selanjutnya + Item sebelumnya diff --git a/libs/horizon/src/main/res/values-is/strings.xml b/libs/horizon/src/main/res/values-is/strings.xml index 658e4709e5..0f6f45e0fe 100644 --- a/libs/horizon/src/main/res/values-is/strings.xml +++ b/libs/horizon/src/main/res/values-is/strings.xml @@ -365,10 +365,36 @@ %1$d\%% lokið Við gátum ekki hlaðið þetta efni. Reyndu aftur. - Reyna aftur - Velkomin(n)! Skoðaðu brautina þína til að innrita þig í fyrsta námskeiðið. + Glæða Upplýsingar um braut + Tími + Þessi græja verður uppfærð um leið og gögn verða tiltæk. + Við gátum ekki hlaðið þetta efni.\nReyndu aftur. + Glæða + klukkutímar í + klukkutímar í námskeiðinu þínu + öll námskeið + Hápunktar færni + Engin gögn enn + Þessi græja verður uppfærð um leið og gögn verða tiltæk. + Við gátum ekki hlaðið þetta efni. + Reyndu aftur. + Glæða + Byrjandi + Fær + Lengra komin(n) + Sérfræðingur Til hamingju! Þú hefur lokið námskeiðinu þínu. Skoðaðu framvindu þína og einkunn á síðunni Læra. + Virkni + Lokið + Þessi græja verður uppfærð um leið og gögn verða tiltæk. + Við gátum ekki hlaðið þetta efni.\nReyndu aftur. + Glæða + Færni + áunnið + Þessi græja verður uppfærð um leið og gögn verða tiltæk. + Glæða + Við gátum ekki hlaðið þetta efni.\nReyndu aftur. Velja námskeið Loka Stækkað @@ -379,4 +405,22 @@ Ekki lokið Læst Afvalið + Frá %1$s + Fara í tilkynningu + Við gátum ekki hlaðið þetta efni.\nReyndu aftur. + %1$d af %2$d + Fyrri tilkynning + Næsta tilkynning + Braut + Velkomin/n í %1$s! Skoðaðu brautina þína til að innrita þig í fyrsta námskeiðið. + Þú ert ekki innritaður/innrituð í námskeið. + %1$s græja er að hlaðast + Námskeið + %1$d lokið + %1$d áunnið + %1$d klukkutímar í %2$s + %1$d klukkutímar í námskeiðinu þínu + Glæða + Næsta atriði + Fyrra atriði diff --git a/libs/horizon/src/main/res/values-it/strings.xml b/libs/horizon/src/main/res/values-it/strings.xml index 787f57ba4c..9dbd6d37fc 100644 --- a/libs/horizon/src/main/res/values-it/strings.xml +++ b/libs/horizon/src/main/res/values-it/strings.xml @@ -365,10 +365,36 @@ %1$d\%% di completamento Non siamo riusciti a caricare questo contenuto. Riprova. - Riprova - Benvenuto! Consulta il nostro programma e iscriviti al tuo primo corso. + Aggiorna Dettagli programma + Ora + Questo widget si aggiornerà quando i dati diventeranno disponibili. + Impossibile caricare questo contenuto.\nRiprova. + Aggiorna + ore in + ore nel tuo corso + tutti i corsi + Evidenze competenze + Ancora nessun dato + Questo widget si aggiornerà quando i dati diventeranno disponibili. + Impossibile caricare questo contenuto. + Riprova. + Aggiorna + Principiante + Competenza + Avanzate + Esperto Congratulazioni! Hai completato il corso. Visualizza i tuoi progressi e punteggi nella pagina Apprendi. + Attività + completato + Questo widget si aggiornerà quando i dati diventeranno disponibili. + Impossibile caricare questo contenuto.\nRiprova. + Aggiorna + Competenze + ottenuto + Questo widget si aggiornerà quando i dati diventeranno disponibili. + Aggiorna + Impossibile caricare questo contenuto.\nRiprova. Seleziona corso Chiudi Esteso @@ -379,4 +405,22 @@ Non completato Bloccato Non selezionato + Da %1$s + Vai all’annuncio + Impossibile caricare questo contenuto.\nRiprova. + %1$d di %2$d + Annuncio precedente + Annuncio successivo + Programma + Benvenuto in %1$s. Consulta il nostro programma e iscriviti al tuo primo corso. + Al momento non sei iscritto ad alcun corso. + Caricamento in corso widget %1$s + Corsi + %1$d completato + %1$d ottenuto + %1$d ore in %2$s + %1$d ore nel tuo corso + Aggiorna + Elemento successivo + Elemento precedente diff --git a/libs/horizon/src/main/res/values-ja/strings.xml b/libs/horizon/src/main/res/values-ja/strings.xml index 5c0368f7c0..86b487bd2d 100644 --- a/libs/horizon/src/main/res/values-ja/strings.xml +++ b/libs/horizon/src/main/res/values-ja/strings.xml @@ -187,8 +187,8 @@ プロンプトを入力 プロンプトを送信 回答を確認 - 再発生 - 再発生 + 再生成 + 再生成 %1$s / %2$s 次のページ 前のページ @@ -359,10 +359,36 @@ %1$d\%% 終了 このコンテンツを読み込めませんでした。 もう一度やり直してください。 - 再試行 - ようこそ!プログラムを表示して、最初のコースに登録してください。 + 更新 プログラムの詳細 + 制限時間 + データが入手可能になり次第、このウィジェットは更新されます。 + このコンテンツを読み込めませんでした。\nもう一度やり直してください。 + 更新 + ()での時間数 + コースでの時間数 + 全コース + スキルハイライト + データはまだありません + データが入手可能になり次第、このウィジェットは更新されます。 + このコンテンツを読み込めませんでした。 + もう一度やり直してください。 + 更新 + 初心者 + 熟達者 + 詳細 + エキスパート おめでとうございます!コースを完了しました。学習ページで進捗状況とスコアを確認してください。 + アクティビティ + 完了 + データが入手可能になり次第、このウィジェットは更新されます。 + このコンテンツを読み込めませんでした。\nもう一度やり直してください。 + 更新 + スキル + 獲得数 + データが入手可能になり次第、このウィジェットは更新されます。 + 更新 + このコンテンツを読み込めませんでした。\nもう一度やり直してください。 コースの選択 閉じる 拡大 @@ -373,4 +399,22 @@ 完了していません ロックされています 選択解除されました + %1$sから + アナウンスへ移動する + このコンテンツを読み込めませんでした。\nもう一度やり直してください。 + %1$d / %2$d + 前回の通知 + 次の通知 + プログラム + %1$sへようこそ!プログラムを表示して、最初のコースに登録してください。 + 現在、コースに登録されていません。 + %1$sウィジェットを読み込み中 + コース + %1$d終了 + %1$d獲得 + %1$d時間(%2$sで) + あなたのコースで%1$d時間 + 更新 + 次の項目 + 前の項目 diff --git a/libs/horizon/src/main/res/values-mi/strings.xml b/libs/horizon/src/main/res/values-mi/strings.xml index c274181a1a..aff15043ea 100644 --- a/libs/horizon/src/main/res/values-mi/strings.xml +++ b/libs/horizon/src/main/res/values-mi/strings.xml @@ -365,10 +365,36 @@ %1$d\%% oti Kaore i taea e matou te uta i tenei ihirangi. Tēnā, whakamātau anō. - Ngana ano - Nau Mai! Tirohia to hotaka ki te whakauru ki to akoranga tuatahi. + Whakahouhia Ngā taipitopito hōtaka + + Ka whakahōu tēnei widget ina wātea mai ngā raraunga. + Kāore i taea e mātou te uta i tēnei ihirangi.\nNgana anō. + Whakahouhia + ngā hāora i roto i + ngā hāora i roto i tō akoranga + ngā akoranga katoa + Ngā Kaupapa Matua o te Pūkenga + Kāore anō kia puta he raraunga + Ka whakahoutia tēnei widget ina wātea mai ngā raraunga. + Kāore i taea e mātou te uta i tēnei ihirangi. + Tēnā, whakamātau anō. + Whakahouhia + Tīmatanga + Mātau + Arā atu + Tohunga Ngā mihi! Kua oti i a koe to akoranga. Tirohia ki te ahunga whakamua me nga kaute i te wharangi Ako. + Ngā Mahi + oti + Ka whakahōu tēnei widget ina wātea mai ngā raraunga. + Kāore i taea e mātou te uta i tēnei ihirangi.\nNgana anō. + Whakahouhia + Pukenga + whiwhi + Ka whakahoutia tēnei widget ina wātea mai ngā raraunga. + Whakahouhia + Kāore i taea e mātou te uta i tēnei ihirangi.\nNgana anō. Tīpako Akoranga Katia Kua rahi ake @@ -379,4 +405,22 @@ Kāore i oti Kua rakaina Kāore i tīpakohia + Mai i %1$s + Haere ki te pānuitanga + Kāore i taea e mātou te uta i tēnei ihirangi.\nNgana anō. + %1$d ō %2$d + Pānuitanga o muri + Pānuitanga e heke mai nei + Hōtaka + Nau mai ki %1$s! Tirohia to hotaka ki te whakauru ki to akoranga tuatahi. + I tenei wa kaore koe i te whakauru ki tetahi akoranga. + %1$s kei te utaina te widget + Ngā Akoranga + %1$d kua oti + %1$d whiwhi + %1$d ngā hāora i roto %2$s + %1$d ngā hāora i roto i tō akoranga + Whakahouhia + Tūemi e heke mai nei + Tūemi o mua diff --git a/libs/horizon/src/main/res/values-ms/strings.xml b/libs/horizon/src/main/res/values-ms/strings.xml index 18c6619bd3..75ea03a4af 100644 --- a/libs/horizon/src/main/res/values-ms/strings.xml +++ b/libs/horizon/src/main/res/values-ms/strings.xml @@ -365,10 +365,36 @@ %1$d\%% selesai Kami tidak dapat memuatkan kandungan ini. Sila cuba semula. - Cuba semula - Selamat datang! Lihat program anda untuk mendaftar dalam kursus pertama anda. + Segar semula Butiran program - Tahniah! Anda telah menyelesaikan kursus anda. Lihat kemajuan dan markah anda pada halaman Belajar. + Masa + Widget ini akan dikemas kini setelah data tersedia. + Kami tidak dapat memuatkan kandungan ini.\nSila cuba lagi. + Segar semula + jam dalam + jam dalam kursus anda + semua kursus + Sorotan Kemahiran + Belum mempunyai data + Widget ini akan dikemas kini setelah data tersedia. + Kami tidak dapat memuatkan kandungan ini. + Sila cuba semula. + Segar semula + Pemula + Mahir + Lanjutan + Pakar + Tahniah! Anda telah melengkapkan kursus anda. Lihat kemajuan dan markah anda pada halaman Belajar. + Aktiviti + dilengkapkan + Widget ini akan dikemas kini setelah data tersedia. + Kami tidak dapat memuatkan kandungan ini.\nSila cuba lagi. + Segar semula + Kemahiran + diperoleh + Widget ini akan dikemas kini setelah data tersedia. + Segar semula + Kami tidak dapat memuatkan kandungan ini.\nSila cuba lagi. Pilih Kursus Tutup Kembangkan @@ -379,4 +405,22 @@ Tidak lengkap Berkunci Nyahpilih + Dari %1$s + Pergi ke pengumuman + Kami tidak dapat memuatkan kandungan ini.\nSila cuba lagi. + %1$d daripada %2$d + Pengumuman sebelumnya + Pengumuman seterusnya + Program + Selamat datang ke %1$s! Lihat program anda untuk mendaftar dalam kursus pertama anda. + Anda tidak didaftarkan dalam kursus buat masa ini. + Widget %1$s sedang dimuatkan + Kursus + %1$d dilengkapkan + %1$d diperoleh + %1$d jam dalam %2$s + %1$d jam dalam kursus anda + Segar semula + Item Seterusnya + Item sebelumnya diff --git a/libs/horizon/src/main/res/values-nb/strings.xml b/libs/horizon/src/main/res/values-nb/strings.xml index 6aa41d50a4..dd89c6bbfa 100644 --- a/libs/horizon/src/main/res/values-nb/strings.xml +++ b/libs/horizon/src/main/res/values-nb/strings.xml @@ -365,10 +365,36 @@ %1$d\%% fullført Vi kunne ikke laste inn dette innholdet. Prøv på nytt. - Prøv igjen - Velkommen! Vis programmet ditt for å melde deg på ditt første emne. + Oppdater Programdetaljer + Tid + Denne widgeten oppdateres når data blir tilgjengelige. + Vi kunne ikke laste inn dette innholdet.\nPrøv på nytt. + Oppdater + timer i + timer i emnet ditt + alle emner + Ferdighetshøydepunkter + Ingen data ennå + Denne widgeten oppdateres når data blir tilgjengelige. + Vi kunne ikke laste inn dette innholdet. + Prøv på nytt. + Oppdater + Nybegynner + Dreven + Avansert + Ekspert Gratulerer! Du har fullført emnet ditt. Se fremgangen og poengsummene dine på Lær-siden. + Aktiviteter + godkjent + Denne widgeten oppdateres når data blir tilgjengelige. + Vi kunne ikke laste inn dette innholdet.\nPrøv på nytt. + Oppdater + Ferdigheter + opptjent + Denne widgeten oppdateres når data blir tilgjengelige. + Oppdater + Vi kunne ikke laste inn dette innholdet.\nPrøv på nytt. Velg emne Lukk Utvidet @@ -379,4 +405,22 @@ Ikke godkjent Låst Ikke valgt + Fra %1$s + Gå til kunngjøring + Vi kunne ikke laste inn dette innholdet.\nPrøv på nytt. + %1$d av %2$d + Forrige kunngjøring + Neste kunngjøring + Program + Velkommen til %1$s! Vis programmet ditt for å melde deg på ditt første emne. + Du er for øyeblikket ikke påmeldt i et emne. + %1$s widget laster inn + Emner + %1$d fullført + %1$d opptjent + %1$d timer i %2$s + %1$d timer i emnet ditt + Oppdater + Neste objekt + Forrige artikkel diff --git a/libs/horizon/src/main/res/values-nl/strings.xml b/libs/horizon/src/main/res/values-nl/strings.xml index b056220fec..893683efaf 100644 --- a/libs/horizon/src/main/res/values-nl/strings.xml +++ b/libs/horizon/src/main/res/values-nl/strings.xml @@ -365,10 +365,36 @@ %1$d\%% voltooid We konden deze inhoud niet laden. Probeer het opnieuw. - Opnieuw proberen - Welkom! Bekijk ons programma om je in te schrijven voor je eerste cursus. + Vernieuwen Programmadetails + Tijd + Deze widget wordt bijgewerkt zodra er gegevens beschikbaar komen. + We konden deze inhoud niet laden.\nProbeer het opnieuw. + Vernieuwen + uren in + uren in je cursus + alle cursussen + Hoogtepunten vaardigheden + Nog geen gegevens + Deze widget wordt bijgewerkt zodra er gegevens beschikbaar komen. + We konden deze inhoud niet laden. + Probeer het opnieuw. + Vernieuwen + Beginner + Vaardig + Geavanceerd + Expert Gefeliciteerd! Je hebt je cursus voltooid. Bekijk je voortgang en scores op de pagina Leren. + Activiteiten + voltooid + Deze widget wordt bijgewerkt zodra er gegevens beschikbaar komen. + We konden deze inhoud niet laden.\nProbeer het opnieuw. + Vernieuwen + Vaardigheden + verdiend + Deze widget wordt bijgewerkt zodra er gegevens beschikbaar komen. + Vernieuwen + We konden deze inhoud niet laden.\nProbeer het opnieuw. Cursus selecteren Sluiten Uitgevouwen @@ -379,4 +405,22 @@ Niet voltooid Vergrendeld Niet geselecteerd + Van %1$s + Ga naar aankondiging + We konden deze inhoud niet laden.\nProbeer het opnieuw. + %1$d van %2$d + Vorige aankondiging + Volgende aankondiging + Programma + Welkom bij %1$s! Bekijk ons programma om je in te schrijven voor je eerste cursus. + Je bent op dit moment niet ingeschreven voor een cursus + %1$s wordt geladen + Cursussen + %1$d voltooid + %1$d verdiend + %1$d uren in %2$s + %1$d uren in je cursus + Vernieuwen + Volgende item + Vorige item diff --git a/libs/horizon/src/main/res/values-pl/strings.xml b/libs/horizon/src/main/res/values-pl/strings.xml index 7a37bb718a..5745b684d3 100644 --- a/libs/horizon/src/main/res/values-pl/strings.xml +++ b/libs/horizon/src/main/res/values-pl/strings.xml @@ -377,10 +377,36 @@ %1$d\%% ukończenia. Nie udało się wczytać tej zawartości. Spróbuj ponownie. - Ponów próbę - Witamy! Wyświetl program, aby zarejestrować się w pierwszym kursie. + Odśwież Szczegóły programu + Czas + Ten widżet zaktualizuje się, gdy dane staną się dostępne. + Nie udało się wczytać tej zawartości.\nSpróbuj ponownie. + Odśwież + godz. w + godz. w kursie + wszystkie kursy + Najważniejsze umiejętności + Brak danych + Ten widżet zaktualizuje się, gdy dane staną się dostępne. + Nie udało się wczytać tej zawartości. + Spróbuj ponownie. + Odśwież + Początkujący + Biegły + Zaawansowane + Ekspert Gratulacje! Ukończono już ten kurs. Wyświetl postępy i wyniki na stronie Nauka. + Działania + gotowe + Ten widżet zaktualizuje się, gdy dane staną się dostępne. + Nie udało się wczytać tej zawartości.\nSpróbuj ponownie. + Odśwież + Umiejętności + zdobyto + Ten widżet zaktualizuje się, gdy dane staną się dostępne. + Odśwież + Nie udało się wczytać tej zawartości.\nSpróbuj ponownie. Wybierz kurs Zamknij Rozwinięte @@ -391,4 +417,22 @@ Nie ukończono Zablokowany Niewybrane + Od %1$s + Przejdź do ogłoszenia + Nie udało się wczytać tej zawartości.\nSpróbuj ponownie. + %1$d z %2$d + Poprzednie ogłoszenie + Następne ogłoszenie + Program + Witamy w %1$s! Wyświetl program, aby zarejestrować się w pierwszym kursie. + Nie zarejestrowano Cię do żadnego kursu. + Wczytywanie widżetu %1$s + Kursy + Ukończone: %1$d + Zdobyto %1$d + %1$d godz. w %2$s + %1$d godz. w kursie + Odśwież + Kolejna pozycja + Poprzednia pozycja diff --git a/libs/horizon/src/main/res/values-pt-rBR/strings.xml b/libs/horizon/src/main/res/values-pt-rBR/strings.xml index 6140e28762..db5976762d 100644 --- a/libs/horizon/src/main/res/values-pt-rBR/strings.xml +++ b/libs/horizon/src/main/res/values-pt-rBR/strings.xml @@ -365,10 +365,36 @@ %1$d\%% concluído Não foi possível carregar esse conteúdo. Por favor, tente novamente. - Tentar novamente - Bem-vindo(a)! Veja seu programa para se matricular em seu primeiro curso. + Atualizar Detalhes do programa + Tempo + Este widget será atualizado assim que os dados estiverem disponíveis. + Não foi possível carregar este conteúdo.\nTente novamente. + Atualizar + horas em + horas em seu curso + todos os cursos + Destaques das habilidades + Ainda não há dados disponíveis. + Este widget será atualizado assim que os dados estiverem disponíveis. + Não foi possível carregar este conteúdo. + Por favor, tente novamente. + Atualizar + Principiante + Proficiente + Avançado + Especialista Parabéns! Você concluiu seu curso. Veja seu progresso e pontuações na página Aprender. + Atividades + concluído + Este widget será atualizado assim que os dados estiverem disponíveis. + Não foi possível carregar este conteúdo.\nTente novamente. + Atualizar + Qualificações + ganhos + Este widget será atualizado assim que os dados estiverem disponíveis. + Atualizar + Não foi possível carregar este conteúdo.\nTente novamente. Selecionar curso Fechar Expandido @@ -379,4 +405,22 @@ Não concluído Bloqueado(a) Não selecionado + De %1$s + Ir para o anúncio + Não foi possível carregar este conteúdo.\nTente novamente. + %1$d de %2$d + Anúncio anterior + Próximo anúncio + Programa + Bem-vindo ao %1$s! Veja seu programa para se matricular em seu primeiro curso. + Você não está matriculado em nenhum curso no momento. + %1$s widget está carregando + Cursos + %1$d completo + %1$d ganhos + %1$d horas em %2$s + %1$d horas em seu curso + Atualizar + Próximo item + Item anterior diff --git a/libs/horizon/src/main/res/values-pt-rPT/strings.xml b/libs/horizon/src/main/res/values-pt-rPT/strings.xml index b40ede6506..d289b712a7 100644 --- a/libs/horizon/src/main/res/values-pt-rPT/strings.xml +++ b/libs/horizon/src/main/res/values-pt-rPT/strings.xml @@ -365,10 +365,36 @@ %1$d\%% completo Não foi possível carregar este conteúdo. Tente novamente. - Tentar novamente - Bem-vindo! Veja o seu programa para se inscrever na sua primeira disciplina. + Atualizar Detalhes do programa + Hora + Este widget será atualizado assim que os dados estiverem disponíveis. + Não foi possível carregar este conteúdo.\nTente novamente. + Atualizar + horas em + horas na sua disciplina + todos os cursos + Destaques de competências + Ainda não há dados + Este widget será atualizado assim que os dados estiverem disponíveis. + Não foi possível carregar este conteúdo. + Tente novamente. + Atualizar + Principiante + Proficiente + Avançado + Especialista Parabéns! Concluiu a sua disciplina. Veja o seu progresso e pontuação na página Aprender. + Atividades + concluído + Este widget será atualizado assim que os dados estiverem disponíveis. + Não foi possível carregar este conteúdo.\nTente novamente. + Atualizar + Competências + ganhou + Este widget será atualizado assim que os dados estiverem disponíveis. + Atualizar + Não foi possível carregar este conteúdo.\nTente novamente. Selecionar disciplina Fechar Expandido @@ -379,4 +405,22 @@ Não está completo Bloqueado Não selecionado + A partir de %1$s + Ir para o anúncio + Não foi possível carregar este conteúdo.\nTente novamente. + %1$d de %2$d + Anúncio anterior + Próximo anúncio + Programa + Bem-vindo ao %1$s! Veja o seu programa para se inscrever na sua primeira disciplina. + Não está inscrito em nenhum curso no momento. + O %1$s widget está a carregar + Disciplinas + %1$d concluído + %1$d ganhou + %1$d horas em %2$s + %1$d horas na sua disciplina + Atualizar + Próximo item + Item anterior diff --git a/libs/horizon/src/main/res/values-ru/strings.xml b/libs/horizon/src/main/res/values-ru/strings.xml index 6b9de8b00c..731197a677 100644 --- a/libs/horizon/src/main/res/values-ru/strings.xml +++ b/libs/horizon/src/main/res/values-ru/strings.xml @@ -377,10 +377,36 @@ %1$d\%% завершено Нам не удалось загрузить этот контент. Повторите попытку. - Повторить - Добро пожаловать! Ознакомьтесь со своей программой для зачисления на свой первый курс. + Обновить Детали программы + Время + Этот виджет обновится, как только данные станут доступны. + Нам не удалось загрузить этот контент.\nПопробуйте еще раз. + Обновить + часов в + часов в вашем курсе + все курсы + Основные навыки + Данные пока отсутствуют + Этот виджет обновится, как только данные станут доступны. + Нам не удалось загрузить этот контент. + Повторите попытку. + Обновить + Начинающий + Специалист + Продвинутый + Эксперт Поздравляем! Вы завершили свой курс. Просмотрите свой прогресс и результаты на странице «Обучение». + Действия + завершено + Этот виджет обновится, как только данные станут доступны. + Нам не удалось загрузить этот контент.\nПопробуйте еще раз. + Обновить + Профессиональные навыки + получено + Этот виджет обновится, как только данные станут доступны. + Обновить + Нам не удалось загрузить этот контент.\nПопробуйте еще раз. Выбрать курс Закрыть В развернутом виде @@ -391,4 +417,22 @@ Не завершено Заблокировано Выбор отменен + От %1$s + Перейти к объявлению + Нам не удалось загрузить этот контент.\nПопробуйте еще раз. + %1$d из %2$d + Предыдущее объявление + Следующее объявление + Программа + Добро пожаловать в %1$s Ознакомьтесь со своей программой для зачисления на свой первый курс. + Вы в настоящее время не зарегистрированы на курс. + Виджет %1$s загружается + Курсы + %1$d завершено + Получено %1$d + %1$d часов в %2$s + %1$d часов в вашем курсе + Обновить + Следующий элемент + Предыдущий элемент diff --git a/libs/horizon/src/main/res/values-sl/strings.xml b/libs/horizon/src/main/res/values-sl/strings.xml index 3928d38234..e6902c6475 100644 --- a/libs/horizon/src/main/res/values-sl/strings.xml +++ b/libs/horizon/src/main/res/values-sl/strings.xml @@ -365,10 +365,36 @@ %1$d\%% dokončano Te vsebine ni bilo mogoče shraniti. Poskusite znova. - Ponovno poskusi - Dobrodošli. Za vpis v prvi predmet si oglejte svoj program. + Osveži Podrobnosti programa - Čestitke! Predmet ste zaključili. Svoj napredek in rezultate si lahko ogledate na strani Učenje. + Čas + Ta pripomoček se bo posodobil, ko bodo podatki na voljo. + Te vsebine ni bilo mogoče naložiti.\nPoskusite znova. + Osveži + ure v + ure v vašem predmetu + vsi predmeti + Poudarki veščin + Še ni podatkov + Ta pripomoček se bo posodobil, ko bodo podatki na voljo. + Te vsebine ni bilo mogoče naložiti. + Poskusite znova. + Osveži + Začetnik + Usposobljen + Napreden + Strokovnjak + Čestitke! Zaključili ste predmet. Svoj napredek in rezultate si lahko ogledate na strani Učenje. + Dejavnosti + zaključeno + Ta pripomoček se bo posodobil, ko bodo podatki na voljo. + Te vsebine ni bilo mogoče naložiti.\nPoskusite znova. + Osveži + Veščine + pridobljeno + Ta pripomoček se bo posodobil, ko bodo podatki na voljo. + Osveži + Te vsebine ni bilo mogoče naložiti.\nPoskusite znova. Izberi predmet Zapri Razširjeno @@ -379,4 +405,22 @@ Ni zaključeno Zaklenjeno Neizbrano + Pošiljatelj %1$s + Na obvestilo + Te vsebine ni bilo mogoče naložiti.\nPoskusite znova. + %1$d od %2$d + Predhodno obvestilo + Naslednje obvestilo + Program + Dobrodošli pri predmetu %1$s. Za vpis v prvi predmet si oglejte svoj program. + Trenutno v predmet niste vpisani. + Pripomoček %1$s se nalaga + Predmeti + %1$d zaključenih + %1$d pridobljeno + %1$d ur v %2$s + %1$d ur v vašem predmetu + Osveži + Naslednji element + Prejšnji element diff --git a/libs/horizon/src/main/res/values-sv/strings.xml b/libs/horizon/src/main/res/values-sv/strings.xml index 08ae464527..ba0957d0d6 100644 --- a/libs/horizon/src/main/res/values-sv/strings.xml +++ b/libs/horizon/src/main/res/values-sv/strings.xml @@ -365,10 +365,36 @@ %1$d\%% slutförd Det gick inte att läsa in detta innehåll. Försök igen. - Försök igen - Välkommen! Visa ditt program för att registrera dig i din första kurs. + Uppdatera Programinformation - Grattis! Du har slutfört din kurs. Visa dina framsteg och poäng på sidan Lär. + Tid + Den här widgeten kommer att uppdateras när data blir tillgängliga. + Det gick inte att läsa in detta innehåll.\nFörsök igen. + Uppdatera + timmar i + timmar i din kurs + alla kurser + Kompetenshöjdpunkter + Inga data än + Den här widgeten kommer att uppdateras när data blir tillgängliga. + Det gick inte att läsa in detta innehåll. + Försök igen. + Uppdatera + Nybörjare + Kunnig + Avancerat + Expert + Grattis! Du har slutfört kursen. Visa dina framsteg och poäng på sidan Lär. + Aktiviteter + färdig + Den här widgeten kommer att uppdateras när data blir tillgängliga. + Det gick inte att läsa in detta innehåll.\nFörsök igen. + Uppdatera + Färdigheter + intjänade + Den här widgeten kommer att uppdateras när data blir tillgängliga. + Uppdatera + Det gick inte att läsa in detta innehåll.\nFörsök igen. Välj kurs Stäng Expanderad @@ -379,4 +405,22 @@ Inte färdig Låst Omarkerade + Från %1$s + Gå till meddelande + Det gick inte att läsa in detta innehåll.\nFörsök igen. + %1$d av %2$d + Tidigare meddelande + Nästa meddelande + Program + Välkommen till %1$s! Visa ditt program för att registrera dig i din första kurs. + Du är för närvarande inte registrerad i en kurs. + %1$s-widgeten läses in + Kurser + %1$d slutförd + %1$d intjänad + %1$d timmar i %2$s + %1$d timmar i din kurs + Uppdatera + Nästa objekt + Föregående objekt diff --git a/libs/horizon/src/main/res/values-th/strings.xml b/libs/horizon/src/main/res/values-th/strings.xml index 9739a67b56..7e1d0abb1c 100644 --- a/libs/horizon/src/main/res/values-th/strings.xml +++ b/libs/horizon/src/main/res/values-th/strings.xml @@ -365,10 +365,36 @@ เสร็จสิ้น %1$d\%% เราไม่สามารถโหลดเนื้อหานี้ กรุณาลองใหม่อีกครั้งในภายหลัง - ลองใหม่ - ยินดีต้อนรับ! ดูหลักสูตรของคุณเพื่อลงทะเบียนบทเรียนแรกของคุณ + รีเฟรช รายละเอียดหลักสูตร + เวลา + วิดเจ็ทนี้จะทำการอัพเดตหลังจากมีข้อมูลเผยแพร่ + เราไม่สามารถโหลดเนื้อหานี้ได้\nกรุณาลองใหม่อีกครั้ง + รีเฟรช + ชั่วโมงใน + ชั่วโมงในบทเรียนของคุณ + บทเรียนทั้งหมด + ข้อมูลน่าสนใจเกี่ยวกับทักษะ + ยังไม่มีข้อมูล + วิดเจ็ทนี้จะทำการอัพเดตหลังจากมีข้อมูลเผยแพร่ + เราไม่สามารถโหลดเนื้อหานี้ + กรุณาลองใหม่อีกครั้งในภายหลัง + รีเฟรช + มือใหม่ + รอบรู้ + ขั้นสูง + เชี่ยวชาญ ยินดีด้วย! คุณจบบทเรียนแล้ว ดูสถานะและคะแนนของคุณได้จากหน้า เรียนรู้ + กิจกรรม + เสร็จสิ้นแล้ว + วิดเจ็ทนี้จะทำการอัพเดตหลังจากมีข้อมูลเผยแพร่ + เราไม่สามารถโหลดเนื้อหานี้ได้\nกรุณาลองใหม่อีกครั้ง + รีเฟรช + ทักษะ + ได้รับ + วิดเจ็ทนี้จะทำการอัพเดตหลังจากมีข้อมูลเผยแพร่ + รีเฟรช + เราไม่สามารถโหลดเนื้อหานี้ได้\nกรุณาลองใหม่อีกครั้ง เลือกบทเรียน ปิด ขยายแล้ว @@ -379,4 +405,22 @@ ไม่เสร็จสิ้น ล็อคแล้ว ไม่ได้เลือก + จาก %1$s + ไปที่ประกาศ + เราไม่สามารถโหลดเนื้อหานี้ได้\nกรุณาลองใหม่อีกครั้ง + %1$d จาก %2$d + ประกาศก่อนหน้า + ประกาศถัดไป + โปรแกรม + ขอต้อนรับสู่ %1$s! ดูหลักสูตรของคุณเพื่อลงทะเบียนบทเรียนแรกของคุณ + ปัจจุบันคุณไม่ได้ลงทะเบียนในบทเรียนใด + วิดเจ็ท %1$s กำลังโหลด + บทเรียน + %1$d รายการที่เสร็จสิ้น + ได้รับ %1$d + %1$d ชั่วโมงใน %2$s + %1$d ชั่วโมงในบทเรียนของคุณ + รีเฟรช + รายการถัดไป + รายการก่อนหน้า diff --git a/libs/horizon/src/main/res/values-vi/strings.xml b/libs/horizon/src/main/res/values-vi/strings.xml index ac4bcba4c0..0a4d9639dc 100644 --- a/libs/horizon/src/main/res/values-vi/strings.xml +++ b/libs/horizon/src/main/res/values-vi/strings.xml @@ -365,10 +365,36 @@ %1$d\%% hoàn thành Chúng tôi không thể tải nội dung này. Vui lòng thử lại. - Thử Lại - Chào mừng! Xem chương trình của bạn để ghi danh vào khóa học đầu tiên. + Làm Mới Chi tiết chương trình + Thời Gian + Tiện ích này sẽ cập nhật khi dữ liệu có sẵn. + Chúng tôi không thể tải nội dung này.\nVui lòng thử lại. + Làm Mới + giờ trong + giờ trong khóa học của bạn + tất cả các khóa học + Điểm Nổi Bật về Kỹ Năng + Chưa có dữ liệu + Tiện ích này sẽ cập nhật khi dữ liệu có sẵn. + Chúng tôi không thể tải nội dung này. + Vui lòng thử lại. + Làm Mới + Người mới bắt đầu + Thành thạo + Nâng cao + Chuyên gia Chúc mừng! Bạn đã hoàn thành khóa học. Xem tiến trình và điểm số của bạn trên trang Học Tập. + Hoạt động + đã hoàn thành + Tiện ích này sẽ cập nhật khi dữ liệu có sẵn. + Chúng tôi không thể tải nội dung này.\nVui lòng thử lại. + Làm Mới + Kỹ Năng + đã đạt được + Tiện ích này sẽ cập nhật khi dữ liệu có sẵn. + Làm Mới + Chúng tôi không thể tải nội dung này.\nVui lòng thử lại. Chọn Khóa Học Đóng Đã Mở Rộng @@ -379,4 +405,22 @@ Chưa hoàn thành Bị Khóa Đã Hủy Chọn + Từ %1$s + Đi đến thông báo + Chúng tôi không thể tải nội dung này.\nVui lòng thử lại. + %1$d / %2$d + Thông báo trước + Thông báo tiếp theo + Chương Trình + Chào mừng bạn đến %1$s! Xem chương trình của bạn để ghi danh vào khóa học đầu tiên. + Bạn hiện chưa ghi danh vào khóa học nào. + Đang tải %1$s tiện ích + Các khóa học + %1$d đã hoàn thành + Đã đạt được %1$d + %1$d giờ trong %2$s + %1$d giờ trong khóa học của bạn + Làm Mới + Mục tiếp theo + Mục trước diff --git a/libs/horizon/src/main/res/values-zh/strings.xml b/libs/horizon/src/main/res/values-zh/strings.xml index 8c70a7e1a4..1fd914603d 100644 --- a/libs/horizon/src/main/res/values-zh/strings.xml +++ b/libs/horizon/src/main/res/values-zh/strings.xml @@ -359,10 +359,36 @@ %1$d\%% 完成 无法加载此内容。 请重试。 - 重试 - 欢迎!查看您的计划以注册第一个课程 + 刷新 计划详情 + 时间 + 数据可用后,小工具将更新。 + 无法加载此内容。\n请重试。 + 刷新 + 小时 + 小时课程时间 + 全部课程 + 技能亮点 + 暂无数据 + 数据可用后,小工具将更新。 + 无法加载此内容。 + 请重试。 + 刷新 + 初学者 + 熟练 + 高级 + 专家 祝贺!您已完成课程。请在学习页面上查看进度和评分。 + 活动 + 完成 + 数据可用后,小工具将更新。 + 无法加载此内容。\n请重试。 + 刷新 + 技能 + 已获 + 数据可用后,小工具将更新。 + 刷新 + 无法加载此内容。\n请重试。 选择课程 关闭 已扩展 @@ -373,4 +399,22 @@ 未完成 已锁定 未选中 + 从 %1$s + 前往公告 + 无法加载此内容。\n请重试。 + %1$d,共%2$d + 上一个公告 + 下一个公告 + 项目 + 欢迎参加 %1$s!查看您的计划以注册第一个课程 + 您目前未注册课程。 + %1$s 小工具加载中 + 课程 + %1$d 已完成 + 已获 %1$d + %1$d 小时 %2$s 时间 + %1$d 小时课程时间 + 刷新 + 下一个项目 + 先前的项目 diff --git a/libs/horizon/src/main/res/values/colors.xml b/libs/horizon/src/main/res/values/colors.xml index 0eda1a2aa0..2dc60c0244 100644 --- a/libs/horizon/src/main/res/values/colors.xml +++ b/libs/horizon/src/main/res/values/colors.xml @@ -125,6 +125,7 @@ #036549 #02533C #024531 + #DAEEEF #37A1AA #1E95A0 #0A8C97 diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index 3e1308d2e4..8e87a2d57e 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -247,10 +247,13 @@ Once you submit this attempt, you won’t be able to make any changes. Cancel Submit attempt - Confusing + Unclear Important + All notes Notebook Filter + All notes + All courses Notes This is where all your notes, taken directly within your learning objects, are stored and organized. It\'s your personal hub for keeping track of key insights, important excerpts, and reflections as you learn. Dive in to review or expand on your notes anytime! Highlight @@ -275,8 +278,8 @@ No messages yet. Copy Add note - Important - Confusing + Mark important + Mark unclear Score: %1$s/%2$s Attempts This assignment allows multiple attempts. Once you\'ve made a submission, you can view it here. @@ -383,7 +386,8 @@ Proficient Advanced Expert - Congrats! You\'ve completed your course. View your progress and scores on the Learn page. + Congrats! + You\'ve completed your course. View your progress and scores on the Learn page. Activities completed This widget will update once data becomes available. @@ -439,5 +443,40 @@ Refresh Next item Previous item - %1$d of %2$d + %1$d of %2$d + Show more + Start capturing your notes + Your notes from learning materials will appear here. Highlight key ideas, reflections, or questions as you learn—they\'ll all be saved in your Notebook for easy review later. + Nothing here yet + Adjust your filters or create a new note to get started. + Failed to load Notebook + Please try again. + Failed to load notes + No due date + Part of: + You’re enrolled in %1$d courses. + See all + See all courses + All courses + Not started + In progress + Completed + All courses + Nothing here yet + Adjust your filters to see more. + Show more + Delete note + Create note + Edit note + Cancel + Save + Delete note + Are you sure you want to delete this note? + Delete + Cancel + Discard changes + Are you sure you want to discard your changes? + Exit + Cancel + Delete note \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt index 9c34deee31..b383942e32 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt @@ -28,10 +28,13 @@ import com.instructure.horizon.features.dashboard.widget.course.DashboardCourseR import com.instructure.horizon.features.dashboard.widget.course.DashboardCourseViewModel import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType +import com.instructure.pandautils.utils.ThemePrefs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.runs import io.mockk.unmockkAll import junit.framework.TestCase.assertEquals @@ -167,6 +170,9 @@ class DashboardCourseViewModelTest { fun setup() { Dispatchers.setMain(testDispatcher) + mockkObject(ThemePrefs) + every { ThemePrefs.brandColor } returns 1 + coEvery { repository.getEnrollments(any()) } returns activeEnrollments + invitedEnrollments + completedEnrollments coEvery { repository.getPrograms(any()) } returns programs coEvery { repository.acceptInvite(any(), any()) } just runs diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerViewModelTest.kt index e787f2b8c2..8aefd97392 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/announcement/DashboardAnnouncementBannerViewModelTest.kt @@ -17,12 +17,16 @@ package com.instructure.horizon.features.dashboard.widget.announcement import android.content.Context +import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.widget.DashboardPaginatedWidgetCardButtonRoute +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 io.mockk.unmockkAll import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue @@ -47,6 +51,9 @@ class DashboardAnnouncementBannerViewModelTest { @Before fun setup() { + mockkObject(ThemePrefs) + every { ThemePrefs.brandColor } returns 1 + every { context.getString(R.string.notificationsAnnouncementCategoryLabel) } returns "Announcement" Dispatchers.setMain(testDispatcher) } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListRepositoryTest.kt new file mode 100644 index 0000000000..13466aa9f6 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListRepositoryTest.kt @@ -0,0 +1,174 @@ +/* + * 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.dashboard.widget.course.list + +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.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.journey.type.ProgramVariantType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +class DashboardCourseListRepositoryTest { + private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val getCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) + private val getProgramsManager: GetProgramsManager = mockk(relaxed = true) + + private val userId = 123L + private val testUser = User(id = userId, name = "Test User") + + private val testCourses = listOf( + CourseWithProgress( + courseId = 1L, + courseName = "Course 1", + courseSyllabus = "Syllabus 1", + progress = 50.0 + ), + CourseWithProgress( + courseId = 2L, + courseName = "Course 2", + courseSyllabus = "Syllabus 2", + progress = 75.0 + ), + CourseWithProgress( + courseId = 3L, + courseName = "Course 3", + courseSyllabus = "Syllabus 3", + progress = 100.0 + ) + ) + + private val testPrograms = listOf( + Program( + id = "program-1", + name = "Program 1", + description = "Description 1", + startDate = null, + endDate = null, + variant = ProgramVariantType.LINEAR, + sortedRequirements = listOf( + ProgramRequirement( + id = "req-1", + progressId = "progress-1", + courseId = 1L, + required = true + ) + ) + ), + Program( + id = "program-2", + name = "Program 2", + description = "Description 2", + startDate = null, + endDate = null, + variant = ProgramVariantType.NON_LINEAR, + sortedRequirements = listOf( + ProgramRequirement( + id = "req-2", + progressId = "progress-2", + courseId = 2L, + required = true + ) + ) + ) + ) + + @Before + fun setup() { + every { apiPrefs.user } returns testUser + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `Test successful courses retrieval without forceRefresh`() = runTest { + coEvery { getCoursesManager.getCoursesWithProgress(userId, false) } returns DataResult.Success(testCourses) + + val result = getRepository().getCourses(false) + + assertEquals(3, result.size) + assertEquals(testCourses, result) + coVerify { getCoursesManager.getCoursesWithProgress(userId, false) } + } + + @Test + fun `Test successful courses retrieval with forceRefresh`() = runTest { + coEvery { getCoursesManager.getCoursesWithProgress(userId, true) } returns DataResult.Success(testCourses) + + val result = getRepository().getCourses(true) + + assertEquals(3, result.size) + assertEquals(testCourses, result) + coVerify { getCoursesManager.getCoursesWithProgress(userId, true) } + } + + @Test(expected = IllegalStateException::class) + fun `Test failed courses retrieval throws exception`() = runTest { + coEvery { getCoursesManager.getCoursesWithProgress(userId, false) } returns DataResult.Fail() + + getRepository().getCourses(false) + } + + @Test + fun `Test successful programs retrieval without forceRefresh`() = runTest { + coEvery { getProgramsManager.getPrograms(false) } returns testPrograms + + val result = getRepository().getPrograms(false) + + assertEquals(2, result.size) + assertEquals(testPrograms, result) + coVerify { getProgramsManager.getPrograms(false) } + } + + @Test + fun `Test successful programs retrieval with forceRefresh`() = runTest { + coEvery { getProgramsManager.getPrograms(true) } returns testPrograms + + val result = getRepository().getPrograms(true) + + assertEquals(2, result.size) + assertEquals(testPrograms, result) + coVerify { getProgramsManager.getPrograms(true) } + } + + @Test(expected = Exception::class) + fun `Test failed programs retrieval throws exception`() = runTest { + coEvery { getProgramsManager.getPrograms(false) } throws Exception("Network error") + + getRepository().getPrograms(false) + } + + private fun getRepository(): DashboardCourseListRepository { + return DashboardCourseListRepository(apiPrefs, getCoursesManager, getProgramsManager) + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListViewModelTest.kt new file mode 100644 index 0000000000..c184d1f699 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/course/list/DashboardCourseListViewModelTest.kt @@ -0,0 +1,324 @@ +/* + * 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.dashboard.widget.course.list + +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program +import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.journey.type.ProgramVariantType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +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 DashboardCourseListViewModelTest { + private val repository: DashboardCourseListRepository = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + private val testCourses = listOf( + CourseWithProgress( + courseId = 1L, + courseName = "Course 1", + courseSyllabus = "", + progress = 0.0 + ), + CourseWithProgress( + courseId = 2L, + courseName = "Course 2", + courseSyllabus = "", + progress = 50.0 + ), + CourseWithProgress( + courseId = 3L, + courseName = "Course 3", + courseSyllabus = "", + progress = 100.0 + ) + ) + + private val testPrograms = listOf( + Program( + id = "program-1", + name = "Program 1", + description = null, + startDate = null, + endDate = null, + variant = ProgramVariantType.LINEAR, + sortedRequirements = listOf( + ProgramRequirement( + id = "req-1", + progressId = "progress-1", + courseId = 1L, + required = true + ) + ) + ), + Program( + id = "program-2", + name = "Program 2", + description = null, + startDate = null, + endDate = null, + variant = ProgramVariantType.LINEAR, + sortedRequirements = listOf( + ProgramRequirement( + id = "req-2", + progressId = "progress-2", + courseId = 2L, + required = true + ) + ) + ) + ) + + private fun createManyCourses(count: Int): List { + return (1..count).map { index -> + CourseWithProgress( + courseId = index.toLong(), + courseName = "Course $index", + courseSyllabus = "", + progress = (index * 10).toDouble() % 100 + ) + } + } + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + coEvery { repository.getCourses(any()) } returns testCourses + coEvery { repository.getPrograms(any()) } returns testPrograms + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `Test successful data load updates UI state`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + + assertFalse(state.loadingState.isLoading) + assertEquals(3, state.courses.size) + assertEquals(10, state.visibleCourseCount) + } + + @Test + fun `Test courses are loaded from repository`() = runTest { + val viewModel = getViewModel() + + coVerify { repository.getCourses(false) } + assertEquals(3, viewModel.uiState.value.courses.size) + } + + @Test + fun `Test programs are loaded from repository`() = runTest { + val viewModel = getViewModel() + + coVerify { repository.getPrograms(false) } + } + + @Test + fun `Test courses are sorted with active courses first`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + + assertEquals(2L, state.courses[0].courseId) + assertEquals(1L, state.courses[1].courseId) + assertEquals(3L, state.courses[2].courseId) + } + + @Test + fun `Test parent programs are mapped correctly`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + + val course1 = state.courses.find { it.courseId == 1L } + assertEquals(1, course1?.parentPrograms?.size) + assertEquals("Program 1", course1?.parentPrograms?.get(0)?.programName) + + val course2 = state.courses.find { it.courseId == 2L } + assertEquals(1, course2?.parentPrograms?.size) + assertEquals("Program 2", course2?.parentPrograms?.get(0)?.programName) + + val course3 = state.courses.find { it.courseId == 3L } + assertEquals(0, course3?.parentPrograms?.size) + } + + @Test + fun `Test filter All shows all courses`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.onFilterOptionSelected(DashboardCourseListFilterOption.All) + + val state = viewModel.uiState.value + assertEquals(DashboardCourseListFilterOption.All, state.selectedFilterOption) + assertEquals(3, state.courses.size) + } + + @Test + fun `Test filter NotStarted shows only courses with 0 progress`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.onFilterOptionSelected(DashboardCourseListFilterOption.NotStarted) + + val state = viewModel.uiState.value + assertEquals(DashboardCourseListFilterOption.NotStarted, state.selectedFilterOption) + assertEquals(1, state.courses.size) + assertEquals(0.0, state.courses[0].progress) + } + + @Test + fun `Test filter InProgress shows only courses with progress between 0 and 100`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.onFilterOptionSelected(DashboardCourseListFilterOption.InProgress) + + val state = viewModel.uiState.value + assertEquals(DashboardCourseListFilterOption.InProgress, state.selectedFilterOption) + assertEquals(1, state.courses.size) + assertEquals(50.0, state.courses[0].progress) + } + + @Test + fun `Test filter Completed shows only courses with 100 progress`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.onFilterOptionSelected(DashboardCourseListFilterOption.Completed) + + val state = viewModel.uiState.value + assertEquals(DashboardCourseListFilterOption.Completed, state.selectedFilterOption) + assertEquals(1, state.courses.size) + assertEquals(100.0, state.courses[0].progress) + } + + @Test + fun `Test initial visible course count is 10`() = runTest { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + assertEquals(10, state.visibleCourseCount) + } + + @Test + fun `Test onShowMoreCourses increases visible count by 10`() = runTest { + coEvery { repository.getCourses(any()) } returns createManyCourses(30) + + val viewModel = getViewModel() + + assertEquals(10, viewModel.uiState.value.visibleCourseCount) + + viewModel.uiState.value.onShowMoreCourses() + + assertEquals(20, viewModel.uiState.value.visibleCourseCount) + + viewModel.uiState.value.onShowMoreCourses() + + assertEquals(30, viewModel.uiState.value.visibleCourseCount) + } + + @Test + fun `Test filter change resets visible course count to 10`() = runTest { + coEvery { repository.getCourses(any()) } returns createManyCourses(30) + + val viewModel = getViewModel() + + viewModel.uiState.value.onShowMoreCourses() + viewModel.uiState.value.onShowMoreCourses() + + assertEquals(30, viewModel.uiState.value.visibleCourseCount) + + viewModel.uiState.value.onFilterOptionSelected(DashboardCourseListFilterOption.InProgress) + + assertEquals(10, viewModel.uiState.value.visibleCourseCount) + } + + @Test + fun `Test refresh resets visible course count to 10`() = runTest { + coEvery { repository.getCourses(any()) } returns createManyCourses(30) + + val viewModel = getViewModel() + + viewModel.uiState.value.onShowMoreCourses() + viewModel.uiState.value.onShowMoreCourses() + + assertEquals(30, viewModel.uiState.value.visibleCourseCount) + + viewModel.uiState.value.loadingState.onRefresh() + + assertEquals(10, viewModel.uiState.value.visibleCourseCount) + } + + @Test + fun `Test failed data load sets loading to false`() = runTest { + coEvery { repository.getCourses(any()) } throws Exception("Network error") + + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.loadingState.isLoading) + } + + @Test + fun `Test refresh calls repository with forceRefresh true`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.loadingState.onRefresh() + + coVerify { repository.getCourses(true) } + coVerify { repository.getPrograms(true) } + } + + @Test + fun `Test refresh updates isRefreshing state`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.loadingState.onRefresh() + + assertFalse(viewModel.uiState.value.loadingState.isRefreshing) + } + + @Test + fun `Test empty courses list does not crash`() = runTest { + coEvery { repository.getCourses(any()) } returns emptyList() + + val viewModel = getViewModel() + + assertEquals(0, viewModel.uiState.value.courses.size) + assertFalse(viewModel.uiState.value.loadingState.isLoading) + } + + private fun getViewModel(): DashboardCourseListViewModel { + return DashboardCourseListViewModel(repository) + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookRepositoryTest.kt index 9f53185ef9..7e260c291f 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookRepositoryTest.kt @@ -17,7 +17,12 @@ package com.instructure.horizon.features.notebook import com.apollographql.apollo.api.Optional +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress +import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DataResult import com.instructure.horizon.features.notebook.common.model.NotebookType import com.instructure.redwood.QueryNotesQuery import com.instructure.redwood.type.LearningObjectFilter @@ -25,14 +30,23 @@ import com.instructure.redwood.type.NoteFilterInput import com.instructure.redwood.type.OrderDirection 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.assertNotNull import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test class NotebookRepositoryTest { private val redwoodApiManager: RedwoodApiManager = mockk(relaxed = true) + private val horizonGetCoursesManager: HorizonGetCoursesManager = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) + + @Before + fun setup() { + every { apiPrefs.user } returns User(id = 123L) + } @Test fun `Test successful notes retrieval with filter`() = runTest { @@ -141,7 +155,71 @@ class NotebookRepositoryTest { ), any(), any(), any(), any(), any()) } } + @Test(expected = Exception::class) + fun `Test error handling for notes retrieval`() = runTest { + coEvery { redwoodApiManager.getNotes(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + + getRepository().getNotes() + } + + @Test + fun `Test getCourses returns success`() = runTest { + val mockCourses = listOf( + mockk(relaxed = true) + ) + coEvery { horizonGetCoursesManager.getCoursesWithProgress(any(), any()) } returns DataResult.Success(mockCourses) + + val result = getRepository().getCourses() + + assertEquals(mockCourses, result) + } + + @Test + fun `Test getCourses with forceNetwork true`() = runTest { + val mockCourses = listOf(mockk(relaxed = true)) + coEvery { horizonGetCoursesManager.getCoursesWithProgress(any(), any()) } returns DataResult.Success(mockCourses) + + getRepository().getCourses(forceNetwork = true) + + coVerify { horizonGetCoursesManager.getCoursesWithProgress(userId = 123L, forceNetwork = true) } + } + + @Test + fun `Test getCourses with forceNetwork false`() = runTest { + val mockCourses = listOf(mockk(relaxed = true)) + coEvery { horizonGetCoursesManager.getCoursesWithProgress(any(), any()) } returns DataResult.Success(mockCourses) + + getRepository().getCourses(forceNetwork = false) + + coVerify { horizonGetCoursesManager.getCoursesWithProgress(userId = 123L, forceNetwork = false) } + } + + @Test + fun `Test deleteNote calls redwood API with noteId`() = runTest { + coEvery { redwoodApiManager.deleteNote(any()) } returns Unit + + getRepository().deleteNote("note123") + + coVerify(exactly = 1) { redwoodApiManager.deleteNote("note123") } + } + + @Test(expected = Exception::class) + fun `Test deleteNote propagates API errors`() = runTest { + coEvery { redwoodApiManager.deleteNote(any()) } throws Exception("Delete failed") + + getRepository().deleteNote("note123") + } + + @Test + fun `Test deleteNote with empty noteId calls API`() = runTest { + coEvery { redwoodApiManager.deleteNote(any()) } returns Unit + + getRepository().deleteNote("") + + coVerify(exactly = 1) { redwoodApiManager.deleteNote("") } + } + private fun getRepository(): NotebookRepository { - return NotebookRepository(redwoodApiManager) + return NotebookRepository(redwoodApiManager, horizonGetCoursesManager, apiPrefs) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookViewModelTest.kt index 2a2ab3ced0..b8e3076fff 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/NotebookViewModelTest.kt @@ -16,14 +16,19 @@ */ package com.instructure.horizon.features.notebook +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.managers.graphql.horizon.CourseWithProgress import com.instructure.horizon.features.notebook.common.model.NotebookType import com.instructure.redwood.QueryNotesQuery import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll 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 @@ -38,7 +43,9 @@ import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class NotebookViewModelTest { + private val context: Context = mockk(relaxed = true) private val repository: NotebookRepository = mockk(relaxed = true) + private val savedStateHandle = SavedStateHandle() private val testDispatcher = UnconfinedTestDispatcher() private val testNotes = QueryNotesQuery.Notes( @@ -72,6 +79,7 @@ class NotebookViewModelTest { fun setup() { Dispatchers.setMain(testDispatcher) coEvery { repository.getNotes(any(), any(), any(), any(), any(), any(), any()) } returns testNotes + coEvery { repository.getCourses(any()) } returns emptyList() } @After @@ -84,7 +92,7 @@ class NotebookViewModelTest { fun `Test data loads successfully on init`() { val viewModel = getViewModel() - assertFalse(viewModel.uiState.value.isLoading) + assertFalse(viewModel.uiState.value.loadingState.isLoading) coVerify { repository.getNotes(any(), any(), any(), any(), any(), any(), any()) } } @@ -101,7 +109,7 @@ class NotebookViewModelTest { val viewModel = getViewModel() - assertFalse(viewModel.uiState.value.isLoading) + assertFalse(viewModel.uiState.value.loadingState.isLoading) assertTrue(viewModel.uiState.value.notes.isEmpty()) } @@ -110,7 +118,6 @@ class NotebookViewModelTest { val viewModel = getViewModel() assertTrue(viewModel.uiState.value.hasNextPage) - assertFalse(viewModel.uiState.value.hasPreviousPage) } @Test @@ -142,61 +149,169 @@ class NotebookViewModelTest { } @Test - fun `Test load previous page uses start cursor`() = runTest { - val notesWithPrevious = testNotes.copy( - pageInfo = testNotes.pageInfo.copy(hasPreviousPage = true) + fun `Test update course id reloads data`() = runTest { + val viewModel = getViewModel() + + viewModel.updateFilters(123L) + viewModel.updateFilters(1234L) + viewModel.updateFilters(123L) + + coVerify(exactly = 2) { repository.getNotes(any(), any(), any(), any(), 123L, any(), any()) } + } + + @Test + fun `Test loadCourses success populates courses`() = runTest { + val mockCourses = listOf( + mockk(relaxed = true), + mockk(relaxed = true) ) - coEvery { repository.getNotes(any(), any(), any(), any(), any(), any(), any()) } returns notesWithPrevious + coEvery { repository.getCourses(any()) } returns mockCourses val viewModel = getViewModel() - viewModel.uiState.value.loadPreviousPage() + assertEquals(mockCourses, viewModel.uiState.value.courses) + } + + @Test + fun `Test loadCourses failure sets empty courses`() = runTest { + coEvery { repository.getCourses(any()) } returns emptyList() + + val viewModel = getViewModel() - coVerify { repository.getNotes(after = null, before = "startCursor1", any(), any(), any(), any(), any()) } + assertTrue(viewModel.uiState.value.courses.isEmpty()) } @Test - fun `Test update course id reloads data`() = runTest { + fun `Test onCourseSelected updates state and reloads data`() = runTest { + val mockCourse = mockk(relaxed = true) { + every { courseId } returns 123L + } val viewModel = getViewModel() - viewModel.updateCourseId(123L) - viewModel.updateCourseId(1234L) - viewModel.updateCourseId(123L) + viewModel.uiState.value.onCourseSelected(mockCourse) - coVerify(exactly = 2) { repository.getNotes(any(), any(), any(), any(), 123L, any(), any()) } + assertEquals(mockCourse, viewModel.uiState.value.selectedCourse) + coVerify(atLeast = 1) { repository.getNotes(any(), any(), any(), any(), 123L, any(), any()) } + } + + @Test + fun `Test onCourseSelected with null clears course`() = runTest { + val viewModel = getViewModel() + + viewModel.uiState.value.onCourseSelected(null) + + assertNull(viewModel.uiState.value.selectedCourse) } @Test - fun `Test update content with course id hides top bar`() = runTest { + fun `Test updateScreenState changes visibility flags`() = runTest { val viewModel = getViewModel() - viewModel.uiState.value.updateContent(123L, null) + viewModel.updateScreenState( + showNoteTypeFilter = false, + showCourseFilter = true, + showTopBar = true + ) - assertFalse(viewModel.uiState.value.showTopBar) - assertTrue(viewModel.uiState.value.showFilters) + assertFalse(viewModel.uiState.value.showNoteTypeFilter) + assertTrue(viewModel.uiState.value.showCourseFilter) + assertTrue(viewModel.uiState.value.showTopBar) } @Test - fun `Test update content with object type hides filters`() = runTest { + fun `Test loadNextPage triggers with valid next page`() = runTest { + coEvery { repository.getNotes(any(), any(), any(), any(), any(), any(), any()) } returns testNotes.copy( + pageInfo = testNotes.pageInfo.copy(hasNextPage = true) + ) val viewModel = getViewModel() - viewModel.uiState.value.updateContent(123L, Pair("Assignment", "456")) + viewModel.uiState.value.loadNextPage() - assertFalse(viewModel.uiState.value.showTopBar) - assertFalse(viewModel.uiState.value.showFilters) + coVerify(atLeast = 2) { repository.getNotes(any(), any(), any(), any(), any(), any(), any()) } } @Test - fun `Test update content without course id shows top bar and filters`() = runTest { + fun `Test course filter is hidden when courseId is present`() = runTest { + savedStateHandle["courseId"] = "123" + val viewModel = getViewModel() - viewModel.uiState.value.updateContent(null, null) + assertFalse(viewModel.uiState.value.showCourseFilter) + } + + @Test + fun `Test updateShowDeleteConfirmation updates state correctly`() = runTest { + val viewModel = getViewModel() + val testNote = viewModel.uiState.value.notes.first() - assertTrue(viewModel.uiState.value.showTopBar) - assertTrue(viewModel.uiState.value.showFilters) + viewModel.uiState.value.updateShowDeleteConfirmation(testNote) + + assertEquals(testNote, viewModel.uiState.value.showDeleteConfirmationForNote) + } + + @Test + fun `Test updateShowDeleteConfirmation with null clears confirmation`() = runTest { + val viewModel = getViewModel() + val testNote = viewModel.uiState.value.notes.first() + + viewModel.uiState.value.updateShowDeleteConfirmation(testNote) + viewModel.uiState.value.updateShowDeleteConfirmation(null) + + assertNull(viewModel.uiState.value.showDeleteConfirmationForNote) + } + + @Test + fun `Test deleteNote removes note from list after successful deletion`() = runTest { + coEvery { repository.deleteNote(any()) } returns Unit + val viewModel = getViewModel() + val initialNotesCount = viewModel.uiState.value.notes.size + val noteToDelete = viewModel.uiState.value.notes.first() + + viewModel.uiState.value.deleteNote(noteToDelete) + + assertEquals(initialNotesCount - 1, viewModel.uiState.value.notes.size) + assertFalse(viewModel.uiState.value.notes.contains(noteToDelete)) + assertNull(viewModel.uiState.value.deleteLoadingNote) + coVerify(exactly = 1) { repository.deleteNote(noteToDelete.id) } + } + + @Test + fun `Test deleteNote clears loading state on error`() = runTest { + coEvery { repository.deleteNote(any()) } throws Exception("Delete failed") + val viewModel = getViewModel() + val initialNotesCount = viewModel.uiState.value.notes.size + val noteToDelete = viewModel.uiState.value.notes.first() + + viewModel.uiState.value.deleteNote(noteToDelete) + + assertEquals(initialNotesCount, viewModel.uiState.value.notes.size) + assertTrue(viewModel.uiState.value.notes.contains(noteToDelete)) + assertNull(viewModel.uiState.value.deleteLoadingNote) + } + + @Test + fun `Test deleteNote with null note does nothing`() = runTest { + val viewModel = getViewModel() + val initialNotesCount = viewModel.uiState.value.notes.size + + viewModel.uiState.value.deleteNote(null) + + assertEquals(initialNotesCount, viewModel.uiState.value.notes.size) + coVerify(exactly = 0) { repository.deleteNote(any()) } + } + + @Test + fun `Test deleteNote calls repository with correct noteId`() = runTest { + coEvery { repository.deleteNote(any()) } returns Unit + val viewModel = getViewModel() + val noteToDelete = viewModel.uiState.value.notes.first() + + viewModel.uiState.value.deleteNote(noteToDelete) + + coVerify(exactly = 1) { repository.deleteNote(noteToDelete.id) } } private fun getViewModel(): NotebookViewModel { - return NotebookViewModel(repository) + return NotebookViewModel(context, repository, savedStateHandle) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/addedit/add/AddNoteViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/addedit/add/AddNoteViewModelTest.kt new file mode 100644 index 0000000000..790f961c1d --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/addedit/add/AddNoteViewModelTest.kt @@ -0,0 +1,333 @@ +/* + * 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.notebook.addedit.add + +import android.content.Context +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.toRoute +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition +import com.instructure.horizon.R +import com.instructure.horizon.features.notebook.common.model.NotebookType +import com.instructure.horizon.features.notebook.navigation.NotebookRoute +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +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.advanceUntilIdle +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 AddNoteViewModelTest { + private val context: Context = mockk(relaxed = true) + private val repository: AddNoteRepository = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + + private val testCourseId = "123" + private val testObjectType = "Assignment" + private val testObjectId = "456" + private val testHighlightedText = "This is highlighted text" + private val testNoteType = "Important" + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + mockkStatic("androidx.navigation.SavedStateHandleKt") + every { context.getString(R.string.createNoteTitle) } returns "Add note" + every { context.getString(R.string.noteHasBeenSavedMessage) } returns "Note has been saved" + every { context.getString(R.string.failedToSaveNoteMessage) } returns "Failed to save note" + + setupSavedStateHandle( + courseId = testCourseId, + objectType = testObjectType, + objectId = testObjectId, + highlightedText = testHighlightedText, + noteType = testNoteType + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `Test initial state is set correctly from saved state`() { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + + assertEquals("Add note", state.title) + assertEquals(testHighlightedText, state.highlightedData.selectedText) + assertEquals(NotebookType.Important, state.type) + assertTrue(state.hasContentChange) + assertNull(state.onDeleteNote) + } + + @Test + fun `Test initial state with null note type`() { + setupSavedStateHandle(noteType = null) + val viewModel = getViewModel() + + assertNull(viewModel.uiState.value.type) + } + + @Test + fun `Test hasContentChange always returns true for add note`() { + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.hasContentChange) + } + + @Test + fun `Test add note calls repository with correct parameters`() = runTest { + coEvery { repository.addNote(any(), any(), any(), any(), any(), any()) } returns Unit + val viewModel = getViewModel() + var onFinishedCalled = false + + viewModel.uiState.value.onSaveNote { onFinishedCalled = true } + advanceUntilIdle() + + coVerify { + repository.addNote( + courseId = testCourseId, + objectId = testObjectId, + objectType = testObjectType, + highlightedData = any(), + userComment = "", + type = NotebookType.Important + ) + } + assertTrue(onFinishedCalled) + } + + @Test + fun `Test add note with user comment`() = runTest { + coEvery { repository.addNote(any(), any(), any(), any(), any(), any()) } returns Unit + val viewModel = getViewModel() + val userComment = "This is my note" + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue(userComment)) + viewModel.uiState.value.onSaveNote {} + advanceUntilIdle() + + coVerify { + repository.addNote( + courseId = testCourseId, + objectId = testObjectId, + objectType = testObjectType, + highlightedData = any(), + userComment = userComment, + type = any() + ) + } + } + + @Test + fun `Test add note with different type`() = runTest { + coEvery { repository.addNote(any(), any(), any(), any(), any(), any()) } returns Unit + val viewModel = getViewModel() + + viewModel.uiState.value.onTypeChanged(NotebookType.Confusing) + viewModel.uiState.value.onSaveNote {} + advanceUntilIdle() + + coVerify { + repository.addNote( + courseId = testCourseId, + objectId = testObjectId, + objectType = testObjectType, + highlightedData = any(), + userComment = any(), + type = NotebookType.Confusing + ) + } + } + + @Test + fun `Test add note with null type`() = runTest { + coEvery { repository.addNote(any(), any(), any(), any(), any(), any()) } returns Unit + setupSavedStateHandle(noteType = null) + val viewModel = getViewModel() + + viewModel.uiState.value.onSaveNote {} + advanceUntilIdle() + + coVerify { + repository.addNote( + courseId = testCourseId, + objectId = testObjectId, + objectType = testObjectType, + highlightedData = any(), + userComment = any(), + type = null + ) + } + } + + @Test + fun `Test add note shows loading state during save`() = runTest { + coEvery { repository.addNote(any(), any(), any(), any(), any(), any()) } coAnswers { + kotlinx.coroutines.delay(100) + } + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.isLoading) + + viewModel.uiState.value.onSaveNote {} + + assertTrue(viewModel.uiState.value.isLoading) + + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `Test add note success shows success message`() = runTest { + coEvery { repository.addNote(any(), any(), any(), any(), any(), any()) } returns Unit + val viewModel = getViewModel() + + viewModel.uiState.value.onSaveNote {} + advanceUntilIdle() + + assertEquals("Note has been saved", viewModel.uiState.value.snackbarMessage) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `Test add note failure shows error message`() = runTest { + coEvery { repository.addNote(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + val viewModel = getViewModel() + + viewModel.uiState.value.onSaveNote {} + advanceUntilIdle() + + assertEquals("Failed to save note", viewModel.uiState.value.snackbarMessage) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `Test add note failure does not call onFinished`() = runTest { + coEvery { repository.addNote(any(), any(), any(), any(), any(), any()) } throws Exception("Network error") + val viewModel = getViewModel() + var onFinishedCalled = false + + viewModel.uiState.value.onSaveNote { onFinishedCalled = true } + advanceUntilIdle() + + assertFalse(onFinishedCalled) + } + + @Test + fun `Test type change updates state`() { + val viewModel = getViewModel() + + assertEquals(NotebookType.Important, viewModel.uiState.value.type) + + viewModel.uiState.value.onTypeChanged(NotebookType.Confusing) + + assertEquals(NotebookType.Confusing, viewModel.uiState.value.type) + } + + @Test + fun `Test user comment change updates state`() { + val viewModel = getViewModel() + val newComment = "Updated comment" + + assertEquals("", viewModel.uiState.value.userComment.text) + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue(newComment)) + + assertEquals(newComment, viewModel.uiState.value.userComment.text) + } + + @Test + fun `Test snackbar dismiss clears message`() { + coEvery { repository.addNote(any(), any(), any(), any(), any(), any()) } returns Unit + val viewModel = getViewModel() + + viewModel.uiState.value.onSaveNote {} + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals("Note has been saved", viewModel.uiState.value.snackbarMessage) + + viewModel.uiState.value.onSnackbarDismiss() + + assertNull(viewModel.uiState.value.snackbarMessage) + } + + @Test + fun `Test exit confirmation dialog can be opened and closed`() { + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.showExitConfirmationDialog) + + viewModel.uiState.value.updateExitConfirmationDialog(true) + + assertTrue(viewModel.uiState.value.showExitConfirmationDialog) + + viewModel.uiState.value.updateExitConfirmationDialog(false) + + assertFalse(viewModel.uiState.value.showExitConfirmationDialog) + } + + private fun getViewModel(): AddNoteViewModel { + return AddNoteViewModel(context, repository, savedStateHandle) + } + + private fun setupSavedStateHandle( + courseId: String = testCourseId, + objectType: String = testObjectType, + objectId: String = testObjectId, + highlightedText: String = testHighlightedText, + noteType: String? = testNoteType + ) { + val route = NotebookRoute.AddNotebook( + courseId = courseId, + objectType = objectType, + objectId = objectId, + highlightedTextStartOffset = 0, + highlightedTextEndOffset = 10, + highlightedTextStartContainer = "container1", + highlightedTextEndContainer = "container2", + textSelectionStart = 0, + textSelectionEnd = 10, + highlightedText = highlightedText, + noteType = noteType + ) + every { savedStateHandle.toRoute() } returns route + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/addedit/edit/EditNoteViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/addedit/edit/EditNoteViewModelTest.kt new file mode 100644 index 0000000000..ac92fa3ecd --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/notebook/addedit/edit/EditNoteViewModelTest.kt @@ -0,0 +1,401 @@ +/* + * 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.notebook.addedit.edit + +import android.content.Context +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.toRoute +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition +import com.instructure.horizon.R +import com.instructure.horizon.features.notebook.common.model.NotebookType +import com.instructure.horizon.features.notebook.navigation.NotebookRoute +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +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.advanceUntilIdle +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 EditNoteViewModelTest { + private val context: Context = mockk(relaxed = true) + private val repository: EditNoteRepository = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + + private val testNoteId = "note123" + private val testHighlightedText = "This is highlighted text" + private val testUserComment = "Existing comment" + private val testNoteType = "Important" + private val testLastModifiedDate = "Updated 2 hours ago" + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + mockkStatic("androidx.navigation.SavedStateHandleKt") + every { context.getString(R.string.editNoteTitle) } returns "Edit note" + every { context.getString(R.string.noteHasBeenSavedMessage) } returns "Note has been saved" + every { context.getString(R.string.failedToSaveNoteMessage) } returns "Failed to save note" + every { context.getString(R.string.noteHasBeenDeletedMessage) } returns "Note has been deleted" + every { context.getString(R.string.failedToDeleteNoteMessage) } returns "Failed to delete note" + + setupSavedStateHandle( + noteId = testNoteId, + highlightedText = testHighlightedText, + userComment = testUserComment, + noteType = testNoteType, + updatedAt = testLastModifiedDate + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `Test initial state is set correctly from saved state`() { + val viewModel = getViewModel() + + val state = viewModel.uiState.value + + assertEquals("Edit note", state.title) + assertEquals(testHighlightedText, state.highlightedData.selectedText) + assertEquals(testUserComment, state.userComment.text) + assertEquals(NotebookType.Important, state.type) + assertEquals(testLastModifiedDate, state.lastModifiedDate) + assertFalse(state.hasContentChange) + assertNotNull(state.onDeleteNote) + } + + @Test + fun `Test initial state with null last modified date`() { + setupSavedStateHandle(updatedAt = null) + val viewModel = getViewModel() + + assertNull(viewModel.uiState.value.lastModifiedDate) + } + + @Test + fun `Test hasContentChange returns false initially`() { + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.hasContentChange) + } + + @Test + fun `Test hasContentChange returns true when comment changes`() { + val viewModel = getViewModel() + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue("New comment")) + + assertTrue(viewModel.uiState.value.hasContentChange) + } + + @Test + fun `Test hasContentChange returns true when type changes`() { + val viewModel = getViewModel() + + viewModel.uiState.value.onTypeChanged(NotebookType.Confusing) + + assertTrue(viewModel.uiState.value.hasContentChange) + } + + @Test + fun `Test hasContentChange returns false when values revert to original`() { + val viewModel = getViewModel() + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue("New comment")) + assertTrue(viewModel.uiState.value.hasContentChange) + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue(testUserComment)) + assertFalse(viewModel.uiState.value.hasContentChange) + } + + @Test + fun `Test edit note calls repository with correct parameters`() = runTest { + coEvery { repository.updateNote(any(), any(), any(), any()) } returns Unit + val viewModel = getViewModel() + var onFinishedCalled = false + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue("Updated comment")) + viewModel.uiState.value.onSaveNote { onFinishedCalled = true } + advanceUntilIdle() + + coVerify { + repository.updateNote( + noteId = testNoteId, + userText = "Updated comment", + highlightedData = any(), + type = NotebookType.Important + ) + } + assertTrue(onFinishedCalled) + } + + @Test + fun `Test edit note with different type`() = runTest { + coEvery { repository.updateNote(any(), any(), any(), any()) } returns Unit + val viewModel = getViewModel() + + viewModel.uiState.value.onTypeChanged(NotebookType.Confusing) + viewModel.uiState.value.onSaveNote {} + advanceUntilIdle() + + coVerify { + repository.updateNote( + noteId = testNoteId, + userText = testUserComment, + highlightedData = any(), + type = NotebookType.Confusing + ) + } + } + + @Test + fun `Test edit note shows loading state during save`() = runTest { + coEvery { repository.updateNote(any(), any(), any(), any()) } coAnswers { + kotlinx.coroutines.delay(100) + } + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.isLoading) + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue("Updated")) + viewModel.uiState.value.onSaveNote {} + + assertTrue(viewModel.uiState.value.isLoading) + + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `Test edit note success shows success message`() = runTest { + coEvery { repository.updateNote(any(), any(), any(), any()) } returns Unit + val viewModel = getViewModel() + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue("Updated")) + viewModel.uiState.value.onSaveNote {} + advanceUntilIdle() + + assertEquals("Note has been saved", viewModel.uiState.value.snackbarMessage) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `Test edit note failure shows error message`() = runTest { + coEvery { repository.updateNote(any(), any(), any(), any()) } throws Exception("Network error") + val viewModel = getViewModel() + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue("Updated")) + viewModel.uiState.value.onSaveNote {} + advanceUntilIdle() + + assertEquals("Failed to save note", viewModel.uiState.value.snackbarMessage) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `Test delete note calls repository with correct parameters`() = runTest { + coEvery { repository.deleteNote(any()) } returns Unit + val viewModel = getViewModel() + var onFinishedCalled = false + + viewModel.uiState.value.onDeleteNote?.invoke { onFinishedCalled = true } + advanceUntilIdle() + + coVerify { + repository.deleteNote(noteId = testNoteId) + } + assertTrue(onFinishedCalled) + } + + @Test + fun `Test delete note shows loading state during delete`() = runTest { + coEvery { repository.deleteNote(any()) } coAnswers { + kotlinx.coroutines.delay(100) + } + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.isLoading) + + viewModel.uiState.value.onDeleteNote?.invoke {} + + assertTrue(viewModel.uiState.value.isLoading) + + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `Test delete note success shows success message`() = runTest { + coEvery { repository.deleteNote(any()) } returns Unit + val viewModel = getViewModel() + + viewModel.uiState.value.onDeleteNote?.invoke {} + advanceUntilIdle() + + assertEquals("Note has been deleted", viewModel.uiState.value.snackbarMessage) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `Test delete note failure shows error message`() = runTest { + coEvery { repository.deleteNote(any()) } throws Exception("Network error") + val viewModel = getViewModel() + + viewModel.uiState.value.onDeleteNote?.invoke {} + advanceUntilIdle() + + assertEquals("Failed to delete note", viewModel.uiState.value.snackbarMessage) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `Test delete note failure does not call onFinished`() = runTest { + coEvery { repository.deleteNote(any()) } throws Exception("Network error") + val viewModel = getViewModel() + var onFinishedCalled = false + + viewModel.uiState.value.onDeleteNote?.invoke { onFinishedCalled = true } + advanceUntilIdle() + + assertFalse(onFinishedCalled) + } + + @Test + fun `Test type change updates state and content change flag`() { + val viewModel = getViewModel() + + assertEquals(NotebookType.Important, viewModel.uiState.value.type) + assertFalse(viewModel.uiState.value.hasContentChange) + + viewModel.uiState.value.onTypeChanged(NotebookType.Confusing) + + assertEquals(NotebookType.Confusing, viewModel.uiState.value.type) + assertTrue(viewModel.uiState.value.hasContentChange) + } + + @Test + fun `Test user comment change updates state and content change flag`() { + val viewModel = getViewModel() + val newComment = "Updated comment" + + assertEquals(testUserComment, viewModel.uiState.value.userComment.text) + assertFalse(viewModel.uiState.value.hasContentChange) + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue(newComment)) + + assertEquals(newComment, viewModel.uiState.value.userComment.text) + assertTrue(viewModel.uiState.value.hasContentChange) + } + + @Test + fun `Test snackbar dismiss clears message`() { + coEvery { repository.updateNote(any(), any(), any(), any()) } returns Unit + val viewModel = getViewModel() + + viewModel.uiState.value.onUserCommentChanged(TextFieldValue("Updated")) + viewModel.uiState.value.onSaveNote {} + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals("Note has been saved", viewModel.uiState.value.snackbarMessage) + + viewModel.uiState.value.onSnackbarDismiss() + + assertNull(viewModel.uiState.value.snackbarMessage) + } + + @Test + fun `Test delete confirmation dialog can be opened and closed`() { + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.showDeleteConfirmationDialog) + + viewModel.uiState.value.updateDeleteConfirmationDialog(true) + + assertTrue(viewModel.uiState.value.showDeleteConfirmationDialog) + + viewModel.uiState.value.updateDeleteConfirmationDialog(false) + + assertFalse(viewModel.uiState.value.showDeleteConfirmationDialog) + } + + @Test + fun `Test exit confirmation dialog can be opened and closed`() { + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.showExitConfirmationDialog) + + viewModel.uiState.value.updateExitConfirmationDialog(true) + + assertTrue(viewModel.uiState.value.showExitConfirmationDialog) + + viewModel.uiState.value.updateExitConfirmationDialog(false) + + assertFalse(viewModel.uiState.value.showExitConfirmationDialog) + } + + private fun getViewModel(): EditNoteViewModel { + return EditNoteViewModel(context, repository, savedStateHandle) + } + + private fun setupSavedStateHandle( + noteId: String = testNoteId, + highlightedText: String = testHighlightedText, + userComment: String = testUserComment, + noteType: String = testNoteType, + updatedAt: String? = testLastModifiedDate + ) { + val route = NotebookRoute.EditNotebook( + noteId = noteId, + highlightedTextStartOffset = 0, + highlightedTextEndOffset = 10, + highlightedTextStartContainer = "container1", + highlightedTextEndContainer = "container2", + textSelectionStart = 0, + textSelectionEnd = 10, + highlightedText = highlightedText, + noteType = noteType, + userComment = userComment, + updatedAt = updatedAt + ) + every { savedStateHandle.toRoute() } returns route + } +} diff --git a/libs/pandares/src/main/res/drawable/ic_calendar_solid.xml b/libs/pandares/src/main/res/drawable/ic_calendar_solid.xml new file mode 100644 index 0000000000..22ff01c2e4 --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_calendar_solid.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/libs/pandares/src/main/res/drawable/ic_question_solid.xml b/libs/pandares/src/main/res/drawable/ic_question_solid.xml new file mode 100644 index 0000000000..73ac11a32a --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_question_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/drawable/ic_warning_solid.xml b/libs/pandares/src/main/res/drawable/ic_warning_solid.xml new file mode 100644 index 0000000000..4f00b1ea8d --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_warning_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libs/pandares/src/main/res/values-ar/strings.xml b/libs/pandares/src/main/res/values-ar/strings.xml index aee24ef716..ad1c4a1130 100644 --- a/libs/pandares/src/main/res/values-ar/strings.xml +++ b/libs/pandares/src/main/res/values-ar/strings.xml @@ -296,6 +296,7 @@ على أساس المهام التي تم تقييمها عرض النتيجة المفترضة النتيجة المفترضة + لا يمكن أن تتجاوز الدرجات الحد الأقصى من النقاط %1$s/%2$s (%3$s) %1$s من إجمالي %2$s نقاط، %3$s علبة الوارد @@ -2191,6 +2192,8 @@ كل المهام عامل تصفية الحالة عامل تصفية + عامل التصفية نشط + عامل التصفية غير نشط سياسة النشر متأخر توسيع @@ -2283,4 +2286,20 @@ نقاط تحقق النقاش تواريخ استحقاق متعددة انتهى المساق. يتعذر إرسال الرسائل! + فتح في طريقة عرض التفاصيل + طريقة عرض غامرة + الحالات + التصفية الدقيقة + علامات التمايز + طلاب دون علامات تمايز + الفزر حسب + اسم الطالب القابل للفرز + اسم الطالب + تاريخ الإرسال + حالة الإرسال + اكتب الدرجة هنا + تم إحراز نقاط أكثر من / %s من النقاط + تم إحراز نقاط أقل من / %s من النقاط + تم إحراز %1$s - %2$s + عدة عوامل تصفية diff --git a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml index 79665ff40b..1385ff70f6 100644 --- a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml @@ -277,6 +277,7 @@ Baseret på vurderede opgaver Vis Hvad-du-hvis score Hvad-nu-hvis score + Resultatet kan ikke overstige det maksimale antal point %1$s/%2$s (%3$s) %1$s ud af %2$s point, %3$s Indbakke @@ -2052,6 +2053,8 @@ Alle opgaver Statusfilter Filter + Filter aktivt + Filter inaktivt Politik for indlæg Sen Udvid @@ -2140,4 +2143,20 @@ Diskussionens kontrolpunkter Flere forfaldsdatoer Faget er afsluttet. Kunne ikke sende beskeder! + Åbn i detaljeret visning + Fordybende visning + Statusser + Præcis filtrering + Differentierede tags + Elever uden differentierede tags + Sorter efter + Elevens sorterbare navn + Elevens navn + Afleveringsdato + Afleveringsstatus + Skriv resultat her + Opnåede mere end / %s point + Opnåede mindre end / %s point + Opnåede %1$s - %2$s + Flere filtre diff --git a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml index d77fec757e..7cc0f30d7c 100644 --- a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml @@ -277,6 +277,7 @@ Based on graded assignments Show What-If Score What-If Score + Score cannot exceed maximum points %1$s/%2$s (%3$s) %1$s out of %2$s points, %3$s Inbox @@ -2052,6 +2053,8 @@ All Assignments Status Filter Filter + Filter active + Filter inactive Post policy Late Expand @@ -2140,4 +2143,20 @@ Discussion Checkpoints Multiple Due Dates Subject concluded. Unable to send messages! + Open in Detail View + Immersive view + Statuses + Precise filtering + Differentiation tags + Students without Differentiation tags + Sort by + Student sortable name + Student name + Submission date + Submission status + Write score here + Scored More than / %s pts + Scored Less than / %s pts + Scored %1$s - %2$s + Multiple filters diff --git a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml index 0c74eca2eb..93fed3324d 100644 --- a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml @@ -277,6 +277,7 @@ Based on graded assignments Show What-if Score What-if Score + Score cannot exceed maximum points %1$s/%2$s (%3$s) %1$s out of %2$s points, %3$s Inbox @@ -2052,6 +2053,8 @@ All assignments Status Filter Filter + Filter active + Filter inactive Post policy Late Expand @@ -2140,4 +2143,20 @@ Discussion Checkpoints Multiple Due Dates Module concluded. Unable to send messages! + Open in Detail View + Immersive view + Statuses + Precise filtering + Differentiation tags + Students without Differentiation tags + Sort by + Student sortable name + Student name + Submission date + Submission status + Write score here + Scored More than / %s pts + Scored Less than / %s pts + Scored %1$s - %2$s + Multiple filters diff --git a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml index bb74410ebc..62cd11909e 100644 --- a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml @@ -277,6 +277,7 @@ Basert på vurderte oppgaver Vis Hva-om-poengberegning Hva-om poengberegning + Poengsum kan ikke overstige maksimalt antall poeng %1$s/%2$s (%3$s) %1$s av %2$s poeng, %3$s Innboks @@ -2053,6 +2054,8 @@ Alle oppgaver Statusfilter Filter + Filter aktivt + Filter inaktivt Publiser retningslinjer Sent Utvid @@ -2141,4 +2144,20 @@ Sjekkpunkter for diskusjon Flere forfallsdatoer Fag avsluttet. Kunne ikke sende meldinger! + Åpne i Detaljvisning + Fordypende visning + Statuser + Presis filtrering + Differensieringstagger + Elever uten differensieringstagger + Sorter etter + Elevens sorterbare navn + Elevens Navn + Innleveringsdato + Innleveringsstatus + Skriv poengsum her + Fikk mer enn / %s poeng + Fikk mindre enn / %s poeng + Fikk %1$s - %2$s + Flere filtre diff --git a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml index 837051d5da..076a41adca 100644 --- a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml @@ -277,6 +277,7 @@ Baserat på bedömda uppgifter Visa tänk om-resultat Tänk om-resultat + Resultatet får inte överstiga maxpoängen %1$s/%2$s (%3$s) %1$s av %2$s poäng, %3$s Inkorg @@ -2052,6 +2053,8 @@ Alla uppgifter Statusfilter Filtrera + Filter aktivt + Filter inaktivt Publicera policy Sen Expandera @@ -2140,4 +2143,20 @@ Kontrollpunkter för diskussion Flera inlämningsdatum Kursen slutförd Det gick inte att skicka meddelanden! + Öppna i detaljerad vy + Uppslukande vy + Statusar + Precis filtrering + Differentieringstaggar + Elever utan differentieringstaggar + Sortera efter + Elevens sorterbara efternamn + Elevens namn + Inlämningsdatum + Inlämningsstatus + Ange resultatet här + Fick mer än/%s poäng + Fick mindre än/%s poäng + Fick %1$s–%2$s + Flera filter diff --git a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml index 24d6dcee65..43b08345de 100644 --- a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml @@ -272,6 +272,7 @@ 基於已評分的作業列表 顯示可能得分 可能得分 + 得分不可超過最高分 %1$s/%2$s (%3$s) 得分為 %1$s,滿分為 %2$s,%3$s 收件匣 @@ -2017,6 +2018,8 @@ 所有作業列表 狀態篩選器 篩選器 + 篩選器已啟用 + 篩選器已停用 貼文政策 逾期 展開 @@ -2104,4 +2107,20 @@ 討論區檢查點 多個截止日期 課程結束。無法傳送訊息! + 在詳細資料視圖中開啟 + 沉浸式視圖 + 狀態 + 精確篩選 + 差異化標籤 + 沒有差異化標籤的學生 + 排序依據 + 可排序的學生姓名 + 學生姓名 + 提交項目日期 + 提交項目狀態 + 在此寫下分數 + 得分高於/%s 分 + 得分低於/%s 分 + %1$s - %2$s 分 + 多個篩選器 diff --git a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml index e20db7e0cc..4437da573b 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml @@ -272,6 +272,7 @@ 基于评分的作业 显示假设的分数 假设的分数 + 分数不能超过满分 %1$s/%2$s (%3$s) 得分 %1$s,总分 %2$s,%3$s 收件箱 @@ -2017,6 +2018,8 @@ 所有作业 状态过滤器 筛选器 + 过滤器处于活跃状态 + 过滤器处于不活跃状态 发布政策 迟交 展开 @@ -2104,4 +2107,20 @@ 讨论检查点 多个到期日 课程已结束。无法发送消息! + 在详情视图中打开 + 沉浸式视图 + 状态 + 精确筛选 + 差异化标记 + 无差异化标记的学生 + 排序 + 学生可排序名称 + 学生姓名 + 提交日期 + 提交状态 + 在此处写下分数 + 分数高于 / %s 分 + 分数低于 / %s 分 + 分数 %1$s - %2$s + 多个过滤器 diff --git a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml index 24d6dcee65..43b08345de 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml @@ -272,6 +272,7 @@ 基於已評分的作業列表 顯示可能得分 可能得分 + 得分不可超過最高分 %1$s/%2$s (%3$s) 得分為 %1$s,滿分為 %2$s,%3$s 收件匣 @@ -2017,6 +2018,8 @@ 所有作業列表 狀態篩選器 篩選器 + 篩選器已啟用 + 篩選器已停用 貼文政策 逾期 展開 @@ -2104,4 +2107,20 @@ 討論區檢查點 多個截止日期 課程結束。無法傳送訊息! + 在詳細資料視圖中開啟 + 沉浸式視圖 + 狀態 + 精確篩選 + 差異化標籤 + 沒有差異化標籤的學生 + 排序依據 + 可排序的學生姓名 + 學生姓名 + 提交項目日期 + 提交項目狀態 + 在此寫下分數 + 得分高於/%s 分 + 得分低於/%s 分 + %1$s - %2$s 分 + 多個篩選器 diff --git a/libs/pandares/src/main/res/values-ca/strings.xml b/libs/pandares/src/main/res/values-ca/strings.xml index f98c805903..f82caae3f1 100644 --- a/libs/pandares/src/main/res/values-ca/strings.xml +++ b/libs/pandares/src/main/res/values-ca/strings.xml @@ -277,6 +277,7 @@ Basat en activitats qualificades Mostra la puntuació Què passaria si Puntuació Què passaria si + La puntuació no pot superar la quantitat màxima de punts %1$s/%2$s (%3$s) %1$s de %2$s punts, %3$s Safata d\'entrada @@ -2053,6 +2054,8 @@ Totes les activitats Filtre d\'estat Filtra + Filtre actiu + Filtre inactiu Política de publicació Endarrerit Desplega @@ -2141,4 +2144,20 @@ Ítems dels fòrums Dates de lliurament múltiples L’assignatura ha conclòs. No es poden enviar missatges. + Visualització Obre-ho en detall + Visualització immersiva + Estats + Filtratge precís + Etiquetes de diferenciació + Estudiants sense etiquetes de diferenciació + Ordena per + Nom ordenable de l’estudiant + Nom de l\'estudiant + Data de l’entrega + Estat de l\'entrega + Escriviu la puntuació aquí + La puntuació ha estat de més de / %s punts + La puntuació ha estat de menys de / %s punts + Puntuació de %1$s a %2$s + Diversos filtres diff --git a/libs/pandares/src/main/res/values-cy/strings.xml b/libs/pandares/src/main/res/values-cy/strings.xml index 48ec5c139d..074ba628e0 100644 --- a/libs/pandares/src/main/res/values-cy/strings.xml +++ b/libs/pandares/src/main/res/values-cy/strings.xml @@ -277,6 +277,7 @@ Yn seiliedig ar aseiniadau wedi’u graddio Dangos Sgôr "Beth-os" Sgôr "Beth-os" + Does dim modd i’r sgôr fod yn fwy na’r nifer uchaf o bwyntiau %1$s/%2$s (%3$s) %1$s allan o %2$s pwynt, %3$s Blwch Derbyn @@ -2052,6 +2053,8 @@ Pob Aseiniad Hidlydd Statws Hidlo + Mae’r hidlydd ar waith + Nid yw’r hidlydd ar waith Polisi postio Yn Hwyr Ehangu @@ -2140,4 +2143,20 @@ Pwyntiau Gwirio Trafodaeth Mwy nag un dyddiad cau Cwrs wedi dod i ben. Methu anfon negeseuon! + Agor yn y Wedd Manylion + Gwedd Ymdrwythol + Statws + Hidlo manwl gywir + Tagiau gwahaniaethol + Myfyrwyr heb dagiau Gwahaniaethol + Trefnu yn ôl + Enw dosbarthadwy myfyriwr + Enw’r myfyriwr + Dyddiad cyflwyno + Statws cyflwyno + Ysgrifennwch y sgôr yma + Wedi cael sgôr o fwy na / %s pwynt + Wedi cael sgôr o lai na / %s pwynt + Wedi sgorio %1$s - %2$s + Mwy nag un hidlydd diff --git a/libs/pandares/src/main/res/values-da/strings.xml b/libs/pandares/src/main/res/values-da/strings.xml index 18115f6f86..9917ecb110 100644 --- a/libs/pandares/src/main/res/values-da/strings.xml +++ b/libs/pandares/src/main/res/values-da/strings.xml @@ -277,6 +277,7 @@ Baseret på bedømte opgaver Vis Hvad-du-hvis score Hvad-nu-hvis score + Resultatet kan ikke overstige det maksimale antal point %1$s/%2$s (%3$s) %1$s ud af %2$s point, %3$s Indbakke @@ -2052,6 +2053,8 @@ Alle opgaver Statusfilter Filter + Filter aktivt + Filter inaktivt Politik for indlæg Sen Udvid @@ -2140,4 +2143,20 @@ Diskussionens kontrolpunkter Flere forfaldsdatoer Faget er afsluttet. Kunne ikke sende beskeder! + Åbn i detaljeret visning + Fordybende visning + Statusser + Præcis filtrering + Differentierede tags + Studerende uden differentierede tags + Sorter efter + Studerendes sorterbare navn + Den studerendes navn + Afleveringsdato + Afleveringsstatus + Skriv resultat her + Opnåede mere end / %s point + Opnåede mindre end / %s point + Opnåede %1$s - %2$s + Flere filtre diff --git a/libs/pandares/src/main/res/values-de/strings.xml b/libs/pandares/src/main/res/values-de/strings.xml index 52cd25e6d0..370d234a30 100644 --- a/libs/pandares/src/main/res/values-de/strings.xml +++ b/libs/pandares/src/main/res/values-de/strings.xml @@ -277,6 +277,7 @@ Basierend auf benoteten Aufgaben Die hypothetische Punktzahl anzeigen Hypothetische Punktzahl + Die maximale Punktzahl darf nicht überschritten werden %1$s/%2$s (%3$s) %1$s von %2$s Punkten, %3$s Posteingang @@ -2052,6 +2053,8 @@ Alle Aufgaben Statusfilter Filter + Filter aktiv + Filter inaktiv Post-Richtlinie Verspätet Erweitern @@ -2140,4 +2143,20 @@ Kontrollpunkte für Diskussionen Mehrere Abgabetermine Kurs abgeschlossen. Nachrichten können nicht gesendet werden! + In der Detailansicht öffnen + Immersive Ansicht + Zustände … + Präzises Filtern + Differenzierungs-Tags + Studierende ohne Differenzierungs-Tags + Sortieren nach + sortierbarer Name des/der Studierenden + Name der/des Studierenden + Abgabedatum + Abgabestatus + Punktzahl hier eintragen + Bewertet Mehr als / %s Pkt. + Bewertet Weniger als / %s Pkt. + Bewertet %1$s – %2$s + Mehrere Filter diff --git a/libs/pandares/src/main/res/values-en-rAU/strings.xml b/libs/pandares/src/main/res/values-en-rAU/strings.xml index 12c9b460be..67a33796bc 100644 --- a/libs/pandares/src/main/res/values-en-rAU/strings.xml +++ b/libs/pandares/src/main/res/values-en-rAU/strings.xml @@ -277,6 +277,7 @@ Based on graded assignments Show What-If Score What-If Score + Score cannot exceed maximum points %1$s/%2$s (%3$s) %1$s out of %2$s points, %3$s Inbox @@ -2052,6 +2053,8 @@ All Assignments Status Filter Filter + Filter active + Filter inactive Post policy Late Expand @@ -2140,4 +2143,20 @@ Discussion Checkpoints Multiple Due Dates Course concluded. Unable to send messages! + Open in Detail View + Immersive view + Statuses + Precise filtering + Differentiation tags + Students without Differentiation tags + Sort by + Student sortable name + Student name + Submission date + Submission status + Write score here + Scored More than / %s pts + Scored Less than / %s pts + Scored %1$s - %2$s + Multiple filters diff --git a/libs/pandares/src/main/res/values-en-rCY/strings.xml b/libs/pandares/src/main/res/values-en-rCY/strings.xml index 0c74eca2eb..93fed3324d 100644 --- a/libs/pandares/src/main/res/values-en-rCY/strings.xml +++ b/libs/pandares/src/main/res/values-en-rCY/strings.xml @@ -277,6 +277,7 @@ Based on graded assignments Show What-if Score What-if Score + Score cannot exceed maximum points %1$s/%2$s (%3$s) %1$s out of %2$s points, %3$s Inbox @@ -2052,6 +2053,8 @@ All assignments Status Filter Filter + Filter active + Filter inactive Post policy Late Expand @@ -2140,4 +2143,20 @@ Discussion Checkpoints Multiple Due Dates Module concluded. Unable to send messages! + Open in Detail View + Immersive view + Statuses + Precise filtering + Differentiation tags + Students without Differentiation tags + Sort by + Student sortable name + Student name + Submission date + Submission status + Write score here + Scored More than / %s pts + Scored Less than / %s pts + Scored %1$s - %2$s + Multiple filters diff --git a/libs/pandares/src/main/res/values-en-rGB/strings.xml b/libs/pandares/src/main/res/values-en-rGB/strings.xml index dfea3dcd76..2c97e5c035 100644 --- a/libs/pandares/src/main/res/values-en-rGB/strings.xml +++ b/libs/pandares/src/main/res/values-en-rGB/strings.xml @@ -277,6 +277,7 @@ Based on graded assignments Show What-if Score What-if Score + Score cannot exceed maximum points %1$s/%2$s (%3$s) %1$s out of %2$s points, %3$s Inbox @@ -2052,6 +2053,8 @@ All assignments Status Filter Filter + Filter active + Filter inactive Post policy Late Expand @@ -2140,4 +2143,20 @@ Discussion Checkpoints Multiple Due Dates Course concluded. Unable to send messages! + Open in Detail View + Immersive view + Statuses + Precise filtering + Differentiation tags + Students without Differentiation tags + Sort by + Student sortable name + Student name + Submission date + Submission status + Write score here + Scored More than / %s pts + Scored Less than / %s pts + Scored %1$s - %2$s + Multiple filters diff --git a/libs/pandares/src/main/res/values-en/strings.xml b/libs/pandares/src/main/res/values-en/strings.xml index f48c6ca568..9fee624f81 100644 --- a/libs/pandares/src/main/res/values-en/strings.xml +++ b/libs/pandares/src/main/res/values-en/strings.xml @@ -277,6 +277,7 @@ Based on graded assignments Show What-If Score What-If Score + Score cannot exceed maximum points %1$s/%2$s (%3$s) %1$s out of %2$s points, %3$s Inbox @@ -2069,6 +2070,8 @@ All Assignments Status Filter Filter + Filter active + Filter inactive Post policy Late Expand @@ -2160,4 +2163,20 @@ Discussion Checkpoints Multiple Due Dates Course concluded. Unable to send messages! + Open in Detail View + Immersive view + Statuses + Precise filtering + Differentiation tags + Students without Differentiation tags + Sort by + Student sortable name + Student name + Submission date + Submission status + Write score here + Scored More than / %s pts + Scored Less than / %s pts + Scored %1$s - %2$s + Multiple filters diff --git a/libs/pandares/src/main/res/values-es-rES/strings.xml b/libs/pandares/src/main/res/values-es-rES/strings.xml index dd89f1c2f8..5403df24ba 100644 --- a/libs/pandares/src/main/res/values-es-rES/strings.xml +++ b/libs/pandares/src/main/res/values-es-rES/strings.xml @@ -277,6 +277,7 @@ Basado en actividades evaluadas Mostrar la puntuación hipotética Puntuación hipotética + La puntuación no puede superar el máximo de puntos %1$s/%2$s (%3$s) %1$s de %2$s puntos, %3$s Bandeja de entrada @@ -2054,6 +2055,8 @@ Todas las actividades Filtro de estado Filtrar + Filtro activo + Filtro inactivo Política de publicación Atrasado Expandir @@ -2142,4 +2145,20 @@ Puntos de comprobación del foro de discusión Varias fechas de entrega Asignatura concluida. ¡No se pueden enviar mensajes! + Abrir en vista detallada + Vista inmersiva + Estados + Filtrado preciso + Etiquetas de diferenciación + Estudiantes sin etiquetas de diferenciación + Ordenar por + Nombre ordenable del estudiante + Nombre del estudiante + Fecha de entrega + Estado de entrega + Escribe la puntuación aquí + Ha obtenido una puntuación superior a / %s puntos + Ha obtenido una puntuación inferior a / %s puntos + Ha obtenido una puntuación de %1$s - %2$s + Múltiples filtros diff --git a/libs/pandares/src/main/res/values-es/strings.xml b/libs/pandares/src/main/res/values-es/strings.xml index a949ff77be..103ef174bf 100644 --- a/libs/pandares/src/main/res/values-es/strings.xml +++ b/libs/pandares/src/main/res/values-es/strings.xml @@ -277,6 +277,7 @@ Basado en tareas calificadas Mostrar el puntaje Qué pasaría si Puntaje Qué pasaría si + El puntaje no puede superar el máximo de puntos %1$s/%2$s (%3$s) %1$s de %2$s puntos, %3$s Bandeja de entrada @@ -2052,6 +2053,8 @@ Todas las tareas Filtro de estado Filtrar + Filtro activo + Filtro inactivo Política de publicaciones Atrasado Expandir @@ -2140,4 +2143,20 @@ Puntos de control del foro de discusión Varias fechas límite Curso finalizado. ¡No se pueden enviar mensajes! + Abrir en vista detallada + Vista inmersiva + Estados + Filtrado preciso + Etiquetas de diferenciación + Estudiantes sin etiquetas de diferenciación + Ordenar por + Nombre clasificable del estudiante + Nombre del estudiante + Fecha de entrega + Estado de entrega + Escribir aquí el puntaje + Obtuvo más de / %s pts + Obtuvo menos de / %s pts + Obtuvo de %1$s a %2$s + Filtros múltiples diff --git a/libs/pandares/src/main/res/values-fi/strings.xml b/libs/pandares/src/main/res/values-fi/strings.xml index 342dcf39a9..5f4847fbe4 100644 --- a/libs/pandares/src/main/res/values-fi/strings.xml +++ b/libs/pandares/src/main/res/values-fi/strings.xml @@ -277,6 +277,7 @@ Perustuu arvosteltuihin tehtäviin Näytä Mitä jos -pistemäärä Mitä jos -pistemäärä + Pisteet eivät saa ylittää enimmäispistemäärää %1$s/%2$s (%3$s) %1$s / %2$s pistettä, %3$s Saapuneet @@ -2052,6 +2053,8 @@ Kaikki tehtävät Tilan suodatin Suodatin + Suodatin aktiivinen + Suodatin ei-aktiivinen Lähetä käytäntö Myöhään Laajenna @@ -2140,4 +2143,20 @@ Keskustelun tarkistuskohdat Useita määräpäiviä Kurssi on päättynyt. Viestien lähetys ei onnistu! + Avaa yksityiskohtaisessa näkymässä + Immersiivinen näkymä + Tilat + Täsmällinen suodatus + Eriytysmerkit + Opiskelijat ilman eriyttämistunnisteita + Lajitteluperuste + Opiskelijan lajittelunimi + Opiskelijan nimi + Tehtävälähetyksen päivämäärä + Lähetyksen tila + Kirjoita pistemäärä tähän + Sai enemmän kuin / %s pistettä + Sai vähemmän kuin / %s pistettä + Pistemäärä %1$s - %2$s + Useita suodattimia diff --git a/libs/pandares/src/main/res/values-fr-rCA/strings.xml b/libs/pandares/src/main/res/values-fr-rCA/strings.xml index 1436c7d6e8..bbde1cedfc 100644 --- a/libs/pandares/src/main/res/values-fr-rCA/strings.xml +++ b/libs/pandares/src/main/res/values-fr-rCA/strings.xml @@ -277,6 +277,7 @@ Baser sur les travaux notés Afficher le score hypothétique Score hypothétique + Le score ne peut pas dépasser le nombre maximum de points %1$s/%2$s (%3$s) %1$s de %2$s points, %3$s Boîte de réception @@ -2052,6 +2053,8 @@ Toutes les travaux Filtre d’état Filtre + Filtre actif + Filtre inactif Politique de publication En retard Développer @@ -2140,4 +2143,20 @@ Points de contrôle de la discussion Dates limites multiples Cours achevé. Impossible d’envoyer des messages! + Ouvrir en vue détaillée + Vue immersive + Statuts + Filtrage précis + Étiquettes de différenciation + Élèves sans étiquettes de différenciation + Trier par + Nom répertorié d’élève + Nom de l’élève + Date d’envoi + Statut de l’envoi + Inscrivez le score ici + Score : Plus de/%s pts + Score : Moins de/%s pts + Score : %1$s —- %2$s + Filtres multiples diff --git a/libs/pandares/src/main/res/values-fr/strings.xml b/libs/pandares/src/main/res/values-fr/strings.xml index 89c620ba7c..16df0f5246 100644 --- a/libs/pandares/src/main/res/values-fr/strings.xml +++ b/libs/pandares/src/main/res/values-fr/strings.xml @@ -277,6 +277,7 @@ En fonction des travaux notés Afficher le score hypothétique Score hypothétique + Le score ne peut pas dépasser le nombre de points maximal %1$s/%2$s (%3$s) %1$s sur %2$s points, %3$s Boîte de réception @@ -2052,6 +2053,8 @@ Tous les travaux Filtre d’état Filtre + Filtre actif + Filtre inactif Stratégie de publication En retard Développer @@ -2140,4 +2143,20 @@ Points de contrôle des discussions Dates limites multiples Cours terminés. Impossible d’envoyer des messages ! + Ouvrir dans la vue détaillée + Vue immersive + Statuts + Filtrage de précision + Balises de différenciation + Élèves sans balises de différenciation + Trier par + Nom de tri de l’élève + Nom de l’élève + Date de soumission + Statut de la soumission + Indiquer le score ici + A obtenu plus de / %s pts + A obtenu moins de / %s pts + A obtenu entre %1$s et %2$s + Filtre multiple diff --git a/libs/pandares/src/main/res/values-ga/strings.xml b/libs/pandares/src/main/res/values-ga/strings.xml index 8587676c29..e9dec687d3 100644 --- a/libs/pandares/src/main/res/values-ga/strings.xml +++ b/libs/pandares/src/main/res/values-ga/strings.xml @@ -277,6 +277,7 @@ Bunaithe ar thascanna marcáilte Taispeáin an Scór dá mba rud é Scór dá mba rud é + Ní féidir leis an scór uasta n bpointí a shárú %1$s/%2$s (%3$s) %1$s as %2$s pointí, %3$s Bosca Isteach @@ -2067,6 +2068,8 @@ Gach Tasc Scagaire Stádais Scag + Scagaire gníomhach + Scagaire neamhghníomhach Polasaí postála Déanach Leathnaigh @@ -2155,4 +2158,20 @@ Seicphointí Fóraim Dáta Dlite Iolracha Críochnaíodh an cúrsa. Ní féidir teachtaireachtaí a sheoladh! + Oscail i Radharc Mionsonraithe + Radharc tumtha + Stádais + Scagadh beacht + Clibeanna difreála + Mic léinn nach bhfuil clibeanna Difreála orthu + Sórtáil de réir + Ainm insórtáilte mac léinn + Ainm an mhic léinn + Dáta na huaslódála + Stádas na huaslódála + Scríobh scór anseo + Scóráladh Níos mó ná / %s (b)p(h)ointe + Scóráladh Níos lú ná / %s (b)p(h)ointe + Scóráilte %1$s - %2$s + Scagairí iolracha diff --git a/libs/pandares/src/main/res/values-hi/strings.xml b/libs/pandares/src/main/res/values-hi/strings.xml index d37c3a2b0d..8af83993e3 100644 --- a/libs/pandares/src/main/res/values-hi/strings.xml +++ b/libs/pandares/src/main/res/values-hi/strings.xml @@ -277,6 +277,7 @@ ग्रेड की गई असाइनमेंट के आधार पर क्या-अगर अंक दिखाएं क्या-अगर अंक + अंक अधिकतम पॉइंट्स से अधिक नहीं हो सकते %1$s/%2$s (%3$s) %2$s पॉइंट्स में से %1$s, %3$s इनबॉक्स @@ -2060,6 +2061,8 @@ सभी असाइनमेंट स्थिति फ़िल्टर फ़िल्टर + फ़िल्टर सक्रिय + फ़िल्टर निष्क्रिय पोस्ट नीति विलंब बढ़ाएं @@ -2148,4 +2151,20 @@ चर्चा जांच बिंदु एकाधिक नियत तिथियां पाठ्यक्रम समाप्त हुआ। संदेश भेजने में असमर्थ! + विवरण दृश्य में खोलें + इमर्सिव दृश्य + स्थितियां + सटीक फ़िल्टरिंग + विभेदन टैग + बिना विभेदन टैग वाले छात्र + इस के आधार पर क्रमबद्ध करें: + छात्र क्रमबद्ध नाम + छात्र का नाम + सबमिशन तिथि + सबमिशन स्थिति + यहाँ अंक लिखें + / %s पॉइंट्स से अधिक अंक पाए + / %s पॉइंट्स से कम अंक पाए + %1$s - %2$s अंक पाए + एकाधिक फ़िल्टर diff --git a/libs/pandares/src/main/res/values-ht/strings.xml b/libs/pandares/src/main/res/values-ht/strings.xml index 98f545211e..3f63502b4b 100644 --- a/libs/pandares/src/main/res/values-ht/strings.xml +++ b/libs/pandares/src/main/res/values-ht/strings.xml @@ -277,6 +277,7 @@ Sou baz devwa yo evalye yo Montre Kisa-Si Nòt Kisa-Si Nòt + Nòt la pa ka depase kantite pwen maksimòm yo %1$s/%2$s (%3$s) %1$s sou %2$s pwen, %3$s Bwat resepsyon @@ -2052,6 +2053,8 @@ Tout Sesyon yo Filtè Estati Filtè + Filtè aktive + Filtè pa aktive Politik pòs An reta Elaji @@ -2140,4 +2143,20 @@ Pwen Kontwòl Diskisyon yo Plizyè Delè Kou a fini. Enposib pou voye mesaj! + Ouvri nan Vizyalizasyon detaye + Gade Anplis + Estati + Filtraj ak presizyon + Etikèt Diferansyasyon + Elèv san Etikèt Diferansyasyon + Klase pa + Non triyab pa elèv + Non elèv + Dat soumisyon + Estati Soumisyon + Ekri rezilta a isit la + Te fè plis pase / %s pwen + Te fè mwens pase / %s pwen + Te fè %1$s - %2$s + Plizyè filtè diff --git a/libs/pandares/src/main/res/values-id/strings.xml b/libs/pandares/src/main/res/values-id/strings.xml index a2ad617936..14bc58e7b5 100644 --- a/libs/pandares/src/main/res/values-id/strings.xml +++ b/libs/pandares/src/main/res/values-id/strings.xml @@ -277,6 +277,7 @@ Berdasarkan tugas yang dinilai Tampilkan Skor Bagaimana-Kalau Skor Bagaimana-Kalau + Skor tidak boleh melebihi poin maksimum %1$s/%2$s (%3$s) %1$s dari %2$s poin, %3$s Kotak Masuk @@ -2055,6 +2056,8 @@ Semua Tugas Filter Status Filter + Filter aktif + Filter tidak aktif Kebijakan posting Terlambat Perbesar @@ -2143,4 +2146,20 @@ Checkpoint Diskusi Tanggal Batas Berganda Kursus selesai. Tidak dapat mengirim pesan! + Buka dalam Tampilan Detail + Tampilan imersif + Status + Pemfilteran presisi + Tag diferensiasi + Siswa tanpa tag Diferensiasi + Sortir menurut + Nama siswa yang dapat diurutkan + Nama siswa + Tanggal penyerahan + Status penyerahan + Tuliskan skor di sini + Mendapat skor Lebih dari / %s pts + Mendapat skor Kurang dari / %s pts + Mendapat skor %1$s - %2$s + Gandakan filter diff --git a/libs/pandares/src/main/res/values-is/strings.xml b/libs/pandares/src/main/res/values-is/strings.xml index 3bbe14e16f..c6bbcfbfb2 100644 --- a/libs/pandares/src/main/res/values-is/strings.xml +++ b/libs/pandares/src/main/res/values-is/strings.xml @@ -277,6 +277,7 @@ Byggt á verkefnum með einkunnum Sýna spáða einkunn Spáð einkunn + Einkunn getur ekki farið yfir hámarksstig %1$s/%2$s (%3$s) %1$s af %2$s punktum, %3$s Innhólf @@ -2052,6 +2053,8 @@ Öll verkefni Stöðusía Sía + Sía virk + Sía óvirk Birtingarstefna Seint Víkka @@ -2140,4 +2143,20 @@ Umræðuvörður Margir skiladagar Námskeiði lokið. Ekki var hægt að senda skilaboð! + Opna í ítarlegu yfirliti + Aðgengilegt yfirlit + Stöður + Nákvæm síun + Aðgreind merki + Nemendur án aðgreindra merkja + Raða eftir + Flokkanlegt nafn nemanda + Nafn nemanda + Skiladagur + Staða á skilum + Skrifaðu einkunn hér + Með meira í einkunn en / %s punkta + Með minna í einkunn en / %s punkta + Með einkunn %1$s - %2$s + Margar síur diff --git a/libs/pandares/src/main/res/values-it/strings.xml b/libs/pandares/src/main/res/values-it/strings.xml index 8411798b12..5bba73a3a5 100644 --- a/libs/pandares/src/main/res/values-it/strings.xml +++ b/libs/pandares/src/main/res/values-it/strings.xml @@ -277,6 +277,7 @@ Basato su compiti valutati Mostra punteggio What-If Punteggio What-If + Il punteggio non può superare i punti massimi %1$s/%2$s (%3$s) %1$s su %2$s punti, %3$s Posta in arrivo @@ -2052,6 +2053,8 @@ Tutti i compiti Stato filtro Filtra + Filtro attivo + Filtro non attivo Politica sulla pubblicazione In ritardo Espandi @@ -2140,4 +2143,20 @@ Punti di controllo discussione Più date di scadenza Corso concluso. Impossibile inviare messaggi! + Apri in Visualizzazione dettaglio + Visualizzazione immersiva + Stati + Filtraggio preciso + Tag di differenziazione + Studenti senza tag di differenziazione + Ordina per + Nome ordinabile studente + Nome studente + Data consegna + Stato consegna + Scrivi punteggio qui + Punteggio ottenuto superiore a /%s punti + Punteggio ottenuto inferiore a /%s punti + Punteggio %1$s - %2$s + Filtri multipli diff --git a/libs/pandares/src/main/res/values-ja/strings.xml b/libs/pandares/src/main/res/values-ja/strings.xml index b44f3f6ed3..ace245a87d 100644 --- a/libs/pandares/src/main/res/values-ja/strings.xml +++ b/libs/pandares/src/main/res/values-ja/strings.xml @@ -44,7 +44,7 @@ タップして進捗を表示する 提出に失敗しました タップして詳細を表示する - 提出 & ルブリック + 課題提出とルーブリック コメント & ルブリック この課題の読み込み中、問題がありました。接続を確認して、もう一度お試しください。 タスクの評定を送信 @@ -120,7 +120,7 @@ 提出 提出バージョン ファイル (%d) - ルービック + ルーブリック 提出エラー この提出物は、外部ページへの URL でした。提出時のページの様子を示すスナップショットを追加しました。 提出はまだされていません @@ -272,6 +272,7 @@ 評定済み課題に基づく 仮定のスコアを表示 仮定のスコア + スコアは最大点数を超えることはできません %1$s/%2$s (%3$s) %1$s / %2$s 点、%3$s 受信トレイ @@ -822,7 +823,7 @@ アラート 会議 - 期日 + 提出期限 採点方針 コース内容 ファイル @@ -1579,8 +1580,8 @@ 更新 利用可能性 可視性 - 開始日時 - 終了日時 + 公開日時 + 公開終了日時 From(~から) 利用終了日時 日付 @@ -1866,7 +1867,7 @@ 合計 評定済み課題に基づく 遅延 - 期日 + 提出期限 グループ 評定優先 採点期間 @@ -2000,7 +2001,7 @@ 課題なし 課題グループ 課題タイプ - 期日 + 提出期限 採点が必要です 公開済み 非公開 @@ -2017,6 +2018,8 @@ 全課題 ステータスフィルター フィルタ + フィルター有効 + フィルター無効 投稿ポリシー 遅延 展開 @@ -2104,4 +2107,20 @@ ディスカッションチェックポイント 複数の締切日 コース完了しました。メッセージを送信できません! + 詳細ビューで開く + 没入型ビュー + ステータス + 正確なフィルタリング + 差別化タグ + 差別化タグなしの受講者 + 並べ替え基準 + 受講者の並べ替え可能な名 + 受講者名 + 提出日 + 提出状態 + ここにスコアを入力してください + / %s ポイント超を得点 + / %s ポイント未満を得点 + %1$s - %2$sを得点 + 複数のフィルター diff --git a/libs/pandares/src/main/res/values-mi/strings.xml b/libs/pandares/src/main/res/values-mi/strings.xml index 299039ec62..4f0bf6fdd3 100644 --- a/libs/pandares/src/main/res/values-mi/strings.xml +++ b/libs/pandares/src/main/res/values-mi/strings.xml @@ -277,6 +277,7 @@ I runga i nga taumahi kua tohua Whakāturia aha-Ki te Tohu Aha-Ki te Tohu + Kāore e taea e te kaute te neke atu i ngā tohu mōrahi %1$s/%2$s (%3$s) %1$s waho ō %2$s ngā koinga, %3$s Pouakauru @@ -2052,6 +2053,8 @@ Ngā Whakataunga Katoa Tātari Tūnga Tātari + Tātari hohe + Tātari kore hohe Kaupapa Here Pānui Tūreiti Whakawhānui @@ -2140,4 +2143,20 @@ Matapakinga Takitaki Maha Rā e tika ana Kua mutu te akoranga. Kaore e taea te tuku karere! + Whakatuwheratia ki te Tirohanga Taipitopito + Tirohanga rumaki + Ngā tūnga + Te tātari tika + Ngā tohu wehewehe + Ko nga akonga kaore he tohu rereke + Kōmaka i te + Ingoa kōmaka ākonga + ingoa Ākonga + Te ra tuku + Te mana tuku + Tuhia te kaute ki konei + Neke atu i te / %s ngā tohu + I whiwhihia i raro iho i te / %s ngā tohu + I whiwhihia %1$s - %2$s + Ngā tātari maha diff --git a/libs/pandares/src/main/res/values-ms/strings.xml b/libs/pandares/src/main/res/values-ms/strings.xml index e4f8ff0795..acac5bdd08 100644 --- a/libs/pandares/src/main/res/values-ms/strings.xml +++ b/libs/pandares/src/main/res/values-ms/strings.xml @@ -277,6 +277,7 @@ Berdasarkan tugasan bergred Masukkan Markah Andaian Markah Andaian + Skor tidak boleh melebihi mata maksimum %1$s/%2$s (%3$s) %1$s daripada %2$s mata, %3$s Peti masuk @@ -2058,6 +2059,8 @@ Semua Tugasan Penapis Status Tapis + Penapis aktif + Penapis tidak aktif Dasar siaran Lewat Kembangkan @@ -2146,4 +2149,20 @@ Titik Semakan Perbincangan Berbilang Tarikh Siap Kursus selesaai. Tidak dapat menghantar mesej! + Buka dalam Pandangan Butiran + Pandangan imersif + Status + Penapisan Tepat + Tag terbeza + Pelajar tanpa tag Terbeza + Isih mengikut + Nama pelajar boleh diisih + Nama pelajar + Tarikh Serahan + Status Serahan + Tulis skor di sini + Mendapat skor lebih daripada / %s mata + Mendapat skor kurang daripada / %s mata + Mendapat skor %1$s - %2$s + Berbilang penapis diff --git a/libs/pandares/src/main/res/values-nb/strings.xml b/libs/pandares/src/main/res/values-nb/strings.xml index 5aaea6f447..42c430731f 100644 --- a/libs/pandares/src/main/res/values-nb/strings.xml +++ b/libs/pandares/src/main/res/values-nb/strings.xml @@ -277,6 +277,7 @@ Basert på vurderte oppgaver Vis Hva-om-poengberegning Hva-om poengberegning + Poengsum kan ikke overstige maksimalt antall poeng %1$s/%2$s (%3$s) %1$s av %2$s poeng, %3$s Innboks @@ -2053,6 +2054,8 @@ Alle oppgaver Statusfilter Filter + Filter aktivt + Filter inaktivt Publiser retningslinjer Sent Utvid @@ -2141,4 +2144,20 @@ Sjekkpunkter for diskusjon Flere forfallsdatoer Emne avsluttet. Kunne ikke sende meldinger! + Åpne i Detaljvisning + Fordypende visning + Statuser + Presis filtrering + Differensieringstagger + Studenter uten differensieringstagger + Sorter etter + Studentens sorterbare navn + Studentens Navn + Innleveringsdato + Innleveringsstatus + Skriv poengsum her + Fikk mer enn / %s poeng + Fikk mindre enn / %s poeng + Fikk %1$s - %2$s + Flere filtre diff --git a/libs/pandares/src/main/res/values-nl/strings.xml b/libs/pandares/src/main/res/values-nl/strings.xml index fcdb3f2d84..6469c06478 100644 --- a/libs/pandares/src/main/res/values-nl/strings.xml +++ b/libs/pandares/src/main/res/values-nl/strings.xml @@ -277,6 +277,7 @@ Gebaseerd op beoordeelde opdrachten Toon What-If score What-If score + Score kan het maximaal aantal punten niet overschrijden %1$s/%2$s (%3$s) %1$s van de %2$s punten, %3$s Inbox @@ -2052,6 +2053,8 @@ Alle opdrachten Statusfilter Filter + Filter actief + Filter inactief Beleid voor posten Te laat Uitvouwen @@ -2140,4 +2143,20 @@ Discussiecontrolemomenten Meerdere inleverdatums Cursus afgesloten. Kan geen berichten verzenden! + In Detailweergave openen + Immersieve weergave + Statussen + Nauwkeurige filtering + Differentiatietags + Cursisten zonder differentiatietags + Sorteren op + Sorteerbare naam cursist + Naam van cursist + Inleverdatum + Inleveringsstatus + Noteer hier de score + Scoorde meer dan / %s punten + Scoorde minder dan / %s punten + Scoorde %1$s - %2$s + Meerdere filters diff --git a/libs/pandares/src/main/res/values-pl/strings.xml b/libs/pandares/src/main/res/values-pl/strings.xml index 0df4c7b4ef..f386bb3654 100644 --- a/libs/pandares/src/main/res/values-pl/strings.xml +++ b/libs/pandares/src/main/res/values-pl/strings.xml @@ -287,6 +287,7 @@ Na podstawie ocenionych zadań Pokaż wynik a-może Wynik a-może + Wynik nie może przekroczyć maksymalnej liczby punktów %1$s/%2$s (%3$s) %1$s z %2$s pkt, %3$s Skrzynka odbiorcza @@ -2122,6 +2123,8 @@ Wszystkie zadania Filtr statusów Filtruj + Filtr aktywny + Filtr nieaktywny Zasady publikowania Późno Rozwiń @@ -2212,4 +2215,20 @@ Punkty kontrolne dyskusji Wiele terminów Zakończony kurs. Nie udało się wysłać wiadomości! + Otwórz w widoku szczegółów + Widok immersyjny + Statusy + Dokładnie filtrowanie + Tagi zróżnicowania + Uczestnicy bez tagów zróżnicowania + Sortuj wg + Sortowalna nazwa uczestnika + Nazwa uczestnika + Data przesłania + Stan przesłania + Wpisz wynik tutaj + Uzyskał wynik powyżej / %s pkt + Uzyskał wynik poniżej / %s pkt + Uzyskał wynik %1$s - %2$s + Wiele filtrów diff --git a/libs/pandares/src/main/res/values-pt-rBR/strings.xml b/libs/pandares/src/main/res/values-pt-rBR/strings.xml index d4a79de73f..db3d30b8fa 100644 --- a/libs/pandares/src/main/res/values-pt-rBR/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rBR/strings.xml @@ -277,6 +277,7 @@ Com base em tarefas avaliadas Mostrar Pontuação de “E Se” Pontuação de “E Se” + A pontuação não pode exceder o número máximo de pontos %1$s/%2$s (%3$s) %1$s de %2$s pontos, %3$s Caixa de entrada @@ -2052,6 +2053,8 @@ Todas as tarefas Filtro de status Filtrar + Filtro ativo + Filtro inativo Política de lançamento Atrasado Expandir @@ -2140,4 +2143,20 @@ Checkpoints de discussão Várias datas de entrega Curso concluído. Não é possível enviar mensagens! + Abrir na visualização detalhada + Visão imersiva + Status + Filtragem precisa + Tags de diferenciação + Alunos sem tags de diferenciação + Ordenar por + Nome classificável do aluno + Nome do aluno + Data de envio + Status de envio + Escreva a pontuação aqui + Pontuação superior a / %s pontos + Pontuação inferior a / %s pontos + Pontuado %1$s - %2$s + Filtros múltiplos diff --git a/libs/pandares/src/main/res/values-pt-rPT/strings.xml b/libs/pandares/src/main/res/values-pt-rPT/strings.xml index 08fe159d38..52f968d12b 100644 --- a/libs/pandares/src/main/res/values-pt-rPT/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rPT/strings.xml @@ -277,6 +277,7 @@ Com base em tarefas classificadas Apresentar Pontuação Potencial Pontuação Potencial + A pontuação não pode exceder o máximo de pontos %1$s/%2$s (%3$s) %1$s de %2$s pontos, %3$s Caixa de entrada @@ -2052,6 +2053,8 @@ Todas as Tarefas Filtro de estado Filtrar + Filtrar ativo + Filtrar inativo Postar políticas Atrasado Expandir @@ -2140,4 +2143,20 @@ Pontos de controlo do debate Datas de limite múltiplas Disciplina concluída. Não é possível enviar mensagens! + Abrir em Detalhes + Visualização imersiva + Estado + Filtragem precisa + Etiquetas de diferenciação + Alunos sem etiquetas de diferenciação + Classificar por + Nome classificável do aluno + Nome do aluno + Data de submissão + Estado da submissão + Escreva a pontuação aqui + Pontuou mais que / %s pts + Pontuou menos que / %s pts + Pontuado %1$s - %2$s + Vários filtros diff --git a/libs/pandares/src/main/res/values-ru/strings.xml b/libs/pandares/src/main/res/values-ru/strings.xml index b323837063..afabf4eef4 100644 --- a/libs/pandares/src/main/res/values-ru/strings.xml +++ b/libs/pandares/src/main/res/values-ru/strings.xml @@ -287,6 +287,7 @@ На основе оцененных заданий Показать возможную оценку Возможная оценка + Оценка не может превышать максимальное количество баллов %1$s/%2$s (%3$s) %1$s из %2$s баллов, %3$s Входящие @@ -2122,6 +2123,8 @@ Все задания Фильтр статуса Фильтровать + Фильтр активен + Фильтр неактивен Политика публикации Поздно Развернуть @@ -2212,4 +2215,20 @@ Контрольные точки обсуждения Множественные даты выполнения Курс завершен. Невозможно отправить сообщение! + Открыть в подробном представлении + Иммерсивное представление + Статусы + Точная фильтрация + Теги дифференциации + Учащиеся без тегов дифференциации + Сортировать по + Сортируемое имя учащегося + Имя учащегося + Данные отправки + Статус отправки + Укажите оценку здесь + Оценено больше чем / %s пунктов + Оценено меньше чем / %s пунктов + Оценено %1$s - %2$s + Несколько фильтров diff --git a/libs/pandares/src/main/res/values-sl/strings.xml b/libs/pandares/src/main/res/values-sl/strings.xml index 1bb2795c5f..8d5b4dce00 100644 --- a/libs/pandares/src/main/res/values-sl/strings.xml +++ b/libs/pandares/src/main/res/values-sl/strings.xml @@ -277,6 +277,7 @@ Na podlagi ocenjenih nalog Prikaži simulacijo rezultatov Simulacija rezultatov + Rezultat ne sme preseči največjega števila točk %1$s/%2$s (%3$s) %1$s od %2$s točk, %3$s Pošta @@ -2052,6 +2053,8 @@ Vse naloge Filter stanja Filter + Filter aktiven + Filter neaktiven Pravila o objavah Zamuda Razširi @@ -2140,4 +2143,20 @@ Točke preverjanja razprave Več rokov Predmet zaključen. Sporočil ni bilo mogoče poslati! + Odpri v podrobnem pogledu + Poglobljen pogled + Statusi + Natančno filtriranje + Diferenciacijske oznake + Študenti brez diferenciacijskih oznak + Razvrsti glede na + Ime za razvrščanje študenta + Ime študenta + Datum oddaje + Status oddaje + Tukaj vnesite rezultat + Dosegel rezultat, večji od / %s točk + Dosegel rezultat, manjši od / %s točk + Dosegel rezultat %1$s - %2$s + Več filtrov diff --git a/libs/pandares/src/main/res/values-sv/strings.xml b/libs/pandares/src/main/res/values-sv/strings.xml index 225951db91..2897a21b38 100644 --- a/libs/pandares/src/main/res/values-sv/strings.xml +++ b/libs/pandares/src/main/res/values-sv/strings.xml @@ -277,6 +277,7 @@ Baserat på bedömda uppgifter Visa tänk om-resultat Tänk om-resultat + Resultatet får inte överstiga maxpoängen %1$s/%2$s (%3$s) %1$s av %2$s poäng, %3$s Inkorg @@ -2052,6 +2053,8 @@ Alla uppgifter Statusfilter Filtrera + Filter aktivt + Filter inaktivt Publicera policy Sen Expandera @@ -2140,4 +2143,20 @@ Kontrollpunkter för diskussion Flera inlämningsdatum Kursen slutförd Det gick inte att skicka meddelanden! + Öppna i detaljerad vy + Uppslukande vy + Statusar + Precis filtrering + Differentieringstaggar + Studenter utan differentieringstaggar + Sortera efter + Studentens sorterbara efternamn + Studentens namn + Inlämningsdatum + Inlämningsstatus + Ange resultatet här + Fick mer än/%s poäng + Fick mindre än/%s poäng + Fick %1$s–%2$s + Flera filter diff --git a/libs/pandares/src/main/res/values-th/strings.xml b/libs/pandares/src/main/res/values-th/strings.xml index 46817a7b8f..8c75b87f77 100644 --- a/libs/pandares/src/main/res/values-th/strings.xml +++ b/libs/pandares/src/main/res/values-th/strings.xml @@ -277,6 +277,7 @@ พิจารณาจากภารกิจที่ให้เกรด แสดงคะแนน What-If คะแนน What-If + คะแนนจะต้องไม่เกินคะแนนสูงสุด %1$s/%2$s (%3$s) %1$s จาก %2$s คะแนน, %3$s กล่องจดหมาย @@ -2052,6 +2053,8 @@ ภารกิจทั้งหมด ตัวกรองสถานะ ตัวกรอง + ตัวกรองใช้งาน + ตัวกรองไม่ได้ใช้งาน นโยบายการโพสต์ ล่าช้า ขยาย @@ -2140,4 +2143,20 @@ จุดตรวจสอบส่วนการพูดคุย วันครบกำหนดหลายรายการ บทเรียนสิ้นสุดแล้ว ไม่สามารถส่งข้อความได้! + เปิดในมุมมองรายละเอียด + มุมมองเชิงลึก + สถานะ + คัดกรองอย่างแม่นยำ + แท็กเฉพาะ + ผู้เรียนที่ไม่มีแท็กเฉพาะ + จัดเรียงจาก + ชื่อแบบจัดเรียงได้ของผู้เรียน + ชื่อผู้เรียน + ข้อมูลการจัดส่ง + สถานะผลงานจัดส่ง + เขียนคะแนนที่นี่ + คะแนนมากกว่า / %s คะแนน + คะแนนน้อยกว่า / %s คะแนน + คะแนน %1$s - %2$s + หลายตัวกรอง diff --git a/libs/pandares/src/main/res/values-vi/strings.xml b/libs/pandares/src/main/res/values-vi/strings.xml index ed6cec99d9..8862e51e3e 100644 --- a/libs/pandares/src/main/res/values-vi/strings.xml +++ b/libs/pandares/src/main/res/values-vi/strings.xml @@ -277,6 +277,7 @@ Căn cứ theo bài tập đã được chấm điểm Hiển Thị Điểm Giả Định Điểm Giả Định + Điểm không được vượt quá điểm thành phần tối đa %1$s/%2$s (%3$s) %1$s trên tổng số %2$s điểm thành phần, %3$s Hộp Thư Đến @@ -2053,6 +2054,8 @@ Tất Cả Bài Tập Bộ Lọc Trạng Thái Lọc + Bộ lọc đang hoạt động + Bộ lọc không hoạt động Chính sách đăng bài Trễ Mở Rộng @@ -2141,4 +2144,20 @@ Điểm Kiểm Tra Thảo Luận Nhiều Ngày Đến Hạn Các khóa học đã kết thúc. Không thể gửi tin nhắn! + Mở trong Chế Độ Xem Chi Tiết + Chế độ xem nhập vai + Các Trạng Thái + Lọc chính xác + Thẻ phân biệt + Sinh viên không có Thẻ phân biệt + Sắp xếp theo + Tên sinh viên có thể sắp xếp được + Tên sinh viên + Ngày nộp + Trạng thái nộp + Viết điểm ở đây + Đạt Trên / %s điểm thành phần + Đạt Dưới / %s điểm thành phần + Đã chấm điểm %1$s - %2$s + Nhiều bộ lọc diff --git a/libs/pandares/src/main/res/values-zh/strings.xml b/libs/pandares/src/main/res/values-zh/strings.xml index e20db7e0cc..4437da573b 100644 --- a/libs/pandares/src/main/res/values-zh/strings.xml +++ b/libs/pandares/src/main/res/values-zh/strings.xml @@ -272,6 +272,7 @@ 基于评分的作业 显示假设的分数 假设的分数 + 分数不能超过满分 %1$s/%2$s (%3$s) 得分 %1$s,总分 %2$s,%3$s 收件箱 @@ -2017,6 +2018,8 @@ 所有作业 状态过滤器 筛选器 + 过滤器处于活跃状态 + 过滤器处于不活跃状态 发布政策 迟交 展开 @@ -2104,4 +2107,20 @@ 讨论检查点 多个到期日 课程已结束。无法发送消息! + 在详情视图中打开 + 沉浸式视图 + 状态 + 精确筛选 + 差异化标记 + 无差异化标记的学生 + 排序 + 学生可排序名称 + 学生姓名 + 提交日期 + 提交状态 + 在此处写下分数 + 分数高于 / %s 分 + 分数低于 / %s 分 + 分数 %1$s - %2$s + 多个过滤器 diff --git a/libs/pandares/src/main/res/values/colors.xml b/libs/pandares/src/main/res/values/colors.xml index 1db9a765ba..a4dd06b081 100644 --- a/libs/pandares/src/main/res/values/colors.xml +++ b/libs/pandares/src/main/res/values/colors.xml @@ -105,7 +105,6 @@ #80D0FF #FFB9F1 - #9E58BD #2573DF diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 9fee624f81..cafecc1c61 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1046,6 +1046,7 @@ Download successful Downloading Download complete + Download started. Enable notifications to see progress. Download Notifications Canvas notifications for ongoing downloads. @@ -1375,6 +1376,11 @@ %s unread notifications + + %s to do item + %s to do items + + No annotation selected Email Notifications @@ -1924,6 +1930,8 @@ Sort By No Assignments It looks like assignments haven\'t been created in this space yet. + No Matching Assignments + No assignments match your search. Try a different search term. We\'re having trouble loading your student\'s grades. Please try reloading the page or check back later. We\'re having trouble loading your student\'s course details. Please try reloading the page or check back later. Filter @@ -2096,7 +2104,7 @@ Oops! Something Went Wrong We\'re having trouble showing your tasks right now. Please try again in a bit or head to the app. You’re all done for now! - Looks like you\'re free for the next 4 weeks. Do you want to add some to-dos? + Looks like you are free, you have no upcoming tasks. Do you want to add some to-dos? Let\'s Get You Logged In! To see your to-dos, please log in to your account in the app. It\'ll just take a sec! Open To Do list screen @@ -2179,4 +2187,166 @@ Scored Less than / %s pts Scored %1$s - %2$s Multiple filters + Filter + There was an error loading your to-do items. Please check your connection and try again. + There was an error updating the to-do item. Please check your connection and try again. + No To Dos for now! + It looks like a great time to rest, relax, and recharge. + Done + Undo + %s marked as done + %s marked as not done + Undo + This action cannot be performed offline + + + To Do list Preferences + Visible Items + Show Personal To Dos + Show Calendar Events + Show Completed + Favourite Courses Only + Show tasks from + Show tasks until + Today + This Week + Last Week + 2 Weeks Ago + 3 Weeks Ago + 4 Weeks Ago + Next Week + In 2 Weeks + In 3 Weeks + In 4 Weeks + From %s + Until %s + selected + not selected + + + Course Invitations (%d) + Invitation to %s accepted + Invitation to %s declined + Decline Invitation + Are you sure you want to decline the invitation to %s? + + + + Good morning, %s! + Good afternoon, %s! + Good evening, %s! + Good night, %s! + + + Good morning! + Good afternoon! + Good evening! + Good night! + + + + You\'ve got this. + Keep going — you\'re stronger than you feel right now. + One step at a time is still progress. + Don\'t give up — future you will thank you. + You\'re capable of more than you think. + Even on tough days, you\'re moving forward. + Trust yourself — you\'ve done hard things before. + Progress, not perfection. You\'re doing great. + Hang in there — you\'re not alone in this. + You\'re learning, growing, and doing better than you realize. + It\'s okay to stumble — you\'re still on the right path. + Keep pushing — you\'re closer than you think. + Small wins count too. + Keep going — you\'re closer than you think. + It\'s okay to pause. Breaks are part of learning. + Trying is already a win. + Progress > perfection. + Showing up matters more than you know. + You\'re building skills, even on slow days. + Don\'t forget to breathe — you\'re doing fine. + Your pace is the right pace. + Not everything needs to be figured out today. + You belong here. + Every effort you make adds up. + It\'s okay to start again — as many times as you need. + What feels hard now will feel easier later. + Keep showing up — that\'s what counts. + Small steps move big mountains. + Rest is part of progress too. + You\'re doing better than you realize. + Even slow progress is still progress. + The future isn\'t built in a day — but you\'re on the way. + One assignment, one moment, one step at a time. + You don\'t have to be perfect to make an impact. + Learning is messy — and that\'s normal. + Every try is growth, even if it doesn\'t feel like it. + You\'ve done hard things before — you can do this too. + Your effort matters, even if no one sees it. + It\'s okay to take things slow. + You\'re moving forward, even on quiet days. + The path doesn\'t need to be clear yet — keep walking. + You\'re stronger than you feel right now. + Big goals are built from small steps. + Keep going — future you will thank you. + Even messy progress is still progress. + You\'re not behind — you\'re on your path. + It\'s okay to learn as you go. + You\'re growing in ways you might not see yet. + Your effort today is an investment in tomorrow. + + + + Morning! You\'ve got this — one class, one step at a time. + Not feeling ready? That\'s normal. Just start where you are. + Coffee helps, but kindness to yourself works better. + Tech acting up? Happens to all of us — don\'t stress. + Today doesn\'t need to be perfect, just possible. + Good morning — today is a new chance to learn and grow. + Even small steps this morning move you closer to your goals. + Take a breath — you don\'t need to have everything figured out yet. + Technology can be tricky, but you\'re not alone in learning it. + + + + Halfway there — you\'ve already done more than you think. + Feeling stuck? Everyone hits walls, just don\'t stop climbing. + Jobs, grades, the future… no one has it all figured out yet. + Brain tired? Quick break = better focus later. + Ask for help. Seriously, no one\'s doing this solo. + You\'ve already made it this far today — that\'s something to be proud of. + Need a pause? Recharging is part of learning too. + It\'s okay if the path feels uncertain — skills build step by step. + Reach out if you\'re stuck — support is always closer than it feels. + + + + Made it through the day — that\'s a win in itself. + Missing people? Shoot someone a quick "hey" — it helps. + Even if today felt messy, you showed up. That matters. + Remember: no grade measures your worth. + Relax, laugh, or scroll guilt-free — you earned it. + Well done getting through the day — progress counts, even when it\'s quiet. + Missing friends or mentors? Connection can come in small moments too. + Evenings are for reflection — notice what you\'ve learned today, not just what\'s left to do. + Your effort matters more than perfection. + + + + Still grinding? Respect — but don\'t forget sleep exists. + Tomorrow you\'ll thank yourself for resting tonight. + Anxiety gets louder at night — don\'t believe all its noise. + You\'re not behind, you\'re just on your path. + Close the laptop — your brain needs dreams too. + It\'s okay to rest — tomorrow is waiting with new opportunities. + Learning is a marathon, not a sprint. Be kind to yourself tonight. + If worries feel heavy, remember you don\'t have to carry them alone. + End the day knowing that trying is already an achievement. + + + + Welcome message: %1$s. %2$s + + + Announcements (%d) diff --git a/libs/pandautils/build.gradle b/libs/pandautils/build.gradle index 850901e036..6c48c116c5 100644 --- a/libs/pandautils/build.gradle +++ b/libs/pandautils/build.gradle @@ -42,13 +42,6 @@ android { buildConfigField "boolean", "IS_TESTING", isTesting() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testApplicationId System.getProperty("testApplicationId", "com.instructure.pandautils.test") - javaCompileOptions { - annotationProcessorOptions { - compilerArgumentProviders( - new RoomSchemaArgProvider(new File(projectDir, "schemas")) - ) - } - } ksp { arg("room.schemaLocation", "$projectDir/schemas".toString()) } diff --git a/libs/pandautils/schemas/com.instructure.pandautils.features.dashboard.widget.db.WidgetDatabase/1.json b/libs/pandautils/schemas/com.instructure.pandautils.features.dashboard.widget.db.WidgetDatabase/1.json new file mode 100644 index 0000000000..4c58ae2cf2 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.features.dashboard.widget.db.WidgetDatabase/1.json @@ -0,0 +1,67 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "8d623a6787d0f7221d7a1ce3ef40b0e4", + "entities": [ + { + "tableName": "widget_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`widgetId` TEXT NOT NULL, `configJson` TEXT NOT NULL, PRIMARY KEY(`widgetId`))", + "fields": [ + { + "fieldPath": "widgetId", + "columnName": "widgetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "configJson", + "columnName": "configJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "widgetId" + ] + } + }, + { + "tableName": "widget_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`widgetId` TEXT NOT NULL, `position` INTEGER NOT NULL, `isVisible` INTEGER NOT NULL, PRIMARY KEY(`widgetId`))", + "fields": [ + { + "fieldPath": "widgetId", + "columnName": "widgetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVisible", + "columnName": "isVisible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "widgetId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8d623a6787d0f7221d7a1ce3ef40b0e4')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/schemas/com.instructure.pandautils.features.dashboard.widget.db.WidgetDatabase/2.json b/libs/pandautils/schemas/com.instructure.pandautils.features.dashboard.widget.db.WidgetDatabase/2.json new file mode 100644 index 0000000000..537ab08e89 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.features.dashboard.widget.db.WidgetDatabase/2.json @@ -0,0 +1,79 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "b05a9556c32a1727b88ef73144acf158", + "entities": [ + { + "tableName": "widget_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`widgetId` TEXT NOT NULL, `configJson` TEXT NOT NULL, PRIMARY KEY(`widgetId`))", + "fields": [ + { + "fieldPath": "widgetId", + "columnName": "widgetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "configJson", + "columnName": "configJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "widgetId" + ] + } + }, + { + "tableName": "widget_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`widgetId` TEXT NOT NULL, `position` INTEGER NOT NULL, `isVisible` INTEGER NOT NULL, `isEditable` INTEGER NOT NULL, `isFullWidth` INTEGER NOT NULL, PRIMARY KEY(`widgetId`))", + "fields": [ + { + "fieldPath": "widgetId", + "columnName": "widgetId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVisible", + "columnName": "isVisible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEditable", + "columnName": "isEditable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFullWidth", + "columnName": "isFullWidth", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "widgetId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b05a9556c32a1727b88ef73144acf158')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/14.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/14.json new file mode 100644 index 0000000000..7a8d52f658 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/14.json @@ -0,0 +1,715 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "ff464171a7b4b0ea2e276b2ea98abfaa", + "entities": [ + { + "tableName": "AttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contentType` TEXT, `filename` TEXT, `displayName` TEXT, `url` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `createdAt` INTEGER, `size` INTEGER NOT NULL, `workerId` TEXT, `submissionCommentId` INTEGER, `submissionId` INTEGER, `attempt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT" + }, + { + "fieldPath": "submissionCommentId", + "columnName": "submissionCommentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER" + }, + { + "fieldPath": "attempt", + "columnName": "attempt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "AuthorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, `pronouns` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "EnvironmentFeatureFlags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `featureFlags` TEXT NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "featureFlags", + "columnName": "featureFlags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + } + }, + { + "tableName": "FileUploadInputEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `courseId` INTEGER, `assignmentId` INTEGER, `quizId` INTEGER, `quizQuestionId` INTEGER, `position` INTEGER, `parentFolderId` INTEGER, `action` TEXT NOT NULL, `userId` INTEGER, `attachments` TEXT NOT NULL, `submissionId` INTEGER, `filePaths` TEXT NOT NULL, `attemptId` INTEGER, `notificationId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER" + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quizId", + "columnName": "quizId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quizQuestionId", + "columnName": "quizQuestionId", + "affinity": "INTEGER" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER" + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER" + }, + { + "fieldPath": "filePaths", + "columnName": "filePaths", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER" + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + } + }, + { + "tableName": "MediaCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` TEXT NOT NULL, `displayName` TEXT, `url` TEXT, `mediaType` TEXT, `contentType` TEXT, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + } + }, + { + "tableName": "SubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `authorId` INTEGER NOT NULL, `authorName` TEXT, `authorPronouns` TEXT, `comment` TEXT, `createdAt` INTEGER, `mediaCommentId` TEXT, `attemptId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT" + }, + { + "fieldPath": "authorPronouns", + "columnName": "authorPronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT" + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER" + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "PendingSubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pageId` TEXT NOT NULL, `comment` TEXT, `date` INTEGER NOT NULL, `status` TEXT NOT NULL, `workerId` TEXT, `filePath` TEXT, `attemptId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT" + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT" + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "DashboardFileUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT, `subtitle` TEXT, `courseId` INTEGER, `assignmentId` INTEGER, `attemptId` INTEGER, `folderId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER" + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + } + }, + { + "tableName": "ReminderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `assignmentId` INTEGER NOT NULL, `htmlUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `text` TEXT NOT NULL, `time` INTEGER NOT NULL, `tag` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ModuleBulkProgressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`progressId` INTEGER NOT NULL, `allModules` INTEGER NOT NULL, `skipContentTags` INTEGER NOT NULL, `action` TEXT NOT NULL, `courseId` INTEGER NOT NULL, `affectedIds` TEXT NOT NULL, PRIMARY KEY(`progressId`))", + "fields": [ + { + "fieldPath": "progressId", + "columnName": "progressId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allModules", + "columnName": "allModules", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipContentTags", + "columnName": "skipContentTags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "affectedIds", + "columnName": "affectedIds", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "progressId" + ] + } + }, + { + "tableName": "assignment_filter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userDomain` TEXT NOT NULL, `userId` INTEGER NOT NULL, `contextId` INTEGER NOT NULL, `selectedAssignmentFilters` TEXT NOT NULL, `selectedAssignmentStatusFilter` TEXT, `selectedGroupByOption` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userDomain", + "columnName": "userDomain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contextId", + "columnName": "contextId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "selectedAssignmentFilters", + "columnName": "selectedAssignmentFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedAssignmentStatusFilter", + "columnName": "selectedAssignmentStatusFilter", + "affinity": "TEXT" + }, + { + "fieldPath": "selectedGroupByOption", + "columnName": "selectedGroupByOption", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "FileDownloadProgressEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `progress` INTEGER NOT NULL, `progressState` TEXT NOT NULL, `filePath` TEXT NOT NULL, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressState", + "columnName": "progressState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + } + }, + { + "tableName": "todo_filter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userDomain` TEXT NOT NULL, `userId` INTEGER NOT NULL, `personalTodos` INTEGER NOT NULL, `calendarEvents` INTEGER NOT NULL, `showCompleted` INTEGER NOT NULL, `favoriteCourses` INTEGER NOT NULL, `pastDateRange` TEXT NOT NULL, `futureDateRange` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userDomain", + "columnName": "userDomain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "personalTodos", + "columnName": "personalTodos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calendarEvents", + "columnName": "calendarEvents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showCompleted", + "columnName": "showCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favoriteCourses", + "columnName": "favoriteCourses", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pastDateRange", + "columnName": "pastDateRange", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "futureDateRange", + "columnName": "futureDateRange", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff464171a7b4b0ea2e276b2ea98abfaa')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.studentdb.StudentDb/7.json b/libs/pandautils/schemas/com.instructure.pandautils.room.studentdb.StudentDb/7.json new file mode 100644 index 0000000000..35fd40b5e0 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.studentdb.StudentDb/7.json @@ -0,0 +1,385 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "a55dd1407675fb730e6b5bb08ea1c021", + "entities": [ + { + "tableName": "CreateSubmissionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `submissionEntry` TEXT, `lastActivityDate` INTEGER, `assignmentName` TEXT, `assignmentId` INTEGER NOT NULL, `canvasContext` TEXT NOT NULL, `submissionType` TEXT NOT NULL, `errorFlag` INTEGER NOT NULL, `assignmentGroupCategoryId` INTEGER, `userId` INTEGER NOT NULL, `currentFile` INTEGER NOT NULL, `fileCount` INTEGER NOT NULL, `progress` REAL, `annotatableAttachmentId` INTEGER, `isDraft` INTEGER NOT NULL, `attempt` INTEGER NOT NULL, `mediaType` TEXT, `mediaSource` TEXT, `submission_state` TEXT NOT NULL DEFAULT 'QUEUED', `state_updated_at` INTEGER, `retry_count` INTEGER NOT NULL DEFAULT 0, `last_error_message` TEXT, `canvas_submission_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "submissionEntry", + "columnName": "submissionEntry", + "affinity": "TEXT" + }, + { + "fieldPath": "lastActivityDate", + "columnName": "lastActivityDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "assignmentName", + "columnName": "assignmentName", + "affinity": "TEXT" + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canvasContext", + "columnName": "canvasContext", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionType", + "columnName": "submissionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "errorFlag", + "columnName": "errorFlag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "assignmentGroupCategoryId", + "columnName": "assignmentGroupCategoryId", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentFile", + "columnName": "currentFile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileCount", + "columnName": "fileCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "REAL" + }, + { + "fieldPath": "annotatableAttachmentId", + "columnName": "annotatableAttachmentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDraft", + "columnName": "isDraft", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attempt", + "columnName": "attempt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaSource", + "columnName": "mediaSource", + "affinity": "TEXT" + }, + { + "fieldPath": "submissionState", + "columnName": "submission_state", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'QUEUED'" + }, + { + "fieldPath": "stateUpdatedAt", + "columnName": "state_updated_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastErrorMessage", + "columnName": "last_error_message", + "affinity": "TEXT" + }, + { + "fieldPath": "canvasSubmissionId", + "columnName": "canvas_submission_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "CreatePendingSubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountDomain` TEXT NOT NULL, `canvasContext` TEXT NOT NULL, `assignmentName` TEXT NOT NULL, `assignmentId` INTEGER NOT NULL, `lastActivityDate` INTEGER NOT NULL, `isGroupMessage` INTEGER NOT NULL, `message` TEXT, `mediaPath` TEXT, `currentFile` INTEGER NOT NULL, `fileCount` INTEGER NOT NULL, `progress` REAL, `errorFlag` INTEGER NOT NULL, `attemptId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountDomain", + "columnName": "accountDomain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "canvasContext", + "columnName": "canvasContext", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assignmentName", + "columnName": "assignmentName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastActivityDate", + "columnName": "lastActivityDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroupMessage", + "columnName": "isGroupMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaPath", + "columnName": "mediaPath", + "affinity": "TEXT" + }, + { + "fieldPath": "currentFile", + "columnName": "currentFile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileCount", + "columnName": "fileCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "REAL" + }, + { + "fieldPath": "errorFlag", + "columnName": "errorFlag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "CreateFileSubmissionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dbSubmissionId` INTEGER NOT NULL, `attachmentId` INTEGER, `name` TEXT, `size` INTEGER, `contentType` TEXT, `fullPath` TEXT, `error` TEXT, `errorFlag` INTEGER NOT NULL, FOREIGN KEY(`dbSubmissionId`) REFERENCES `CreateSubmissionEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dbSubmissionId", + "columnName": "dbSubmissionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT" + }, + { + "fieldPath": "fullPath", + "columnName": "fullPath", + "affinity": "TEXT" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + }, + { + "fieldPath": "errorFlag", + "columnName": "errorFlag", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "CreateSubmissionEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dbSubmissionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "CreateSubmissionCommentFileEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pendingCommentId` INTEGER NOT NULL, `attachmentId` INTEGER, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `contentType` TEXT NOT NULL, `fullPath` TEXT NOT NULL, FOREIGN KEY(`pendingCommentId`) REFERENCES `CreatePendingSubmissionCommentEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingCommentId", + "columnName": "pendingCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachmentId", + "columnName": "attachmentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullPath", + "columnName": "fullPath", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "CreatePendingSubmissionCommentEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pendingCommentId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a55dd1407675fb730e6b5bb08ea1c021')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidgetTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidgetTest.kt new file mode 100644 index 0000000000..bd96c1d078 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidgetTest.kt @@ -0,0 +1,359 @@ +/* + * 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.pandautils.features.dashboard.widget.courseinvitation + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.domain.models.enrollment.CourseInvitation +import kotlinx.coroutines.flow.MutableSharedFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CourseInvitationsWidgetTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testWidgetDoesNotShowWhenLoading() { + val uiState = CourseInvitationsUiState( + loading = true, + error = false, + invitations = emptyList() + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + // Widget should not render anything when loading + composeTestRule.onNodeWithText("Course", substring = true).assertDoesNotExist() + } + + @Test + fun testWidgetDoesNotShowWhenError() { + val uiState = CourseInvitationsUiState( + loading = false, + error = true, + invitations = emptyList() + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + // Widget should not render anything when error + composeTestRule.onNodeWithText("Course", substring = true).assertDoesNotExist() + } + + @Test + fun testWidgetDoesNotShowWhenNoInvitations() { + val uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = emptyList() + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + // Widget should not render anything when no invitations + composeTestRule.onNodeWithText("Course", substring = true).assertDoesNotExist() + } + + @Test + fun testWidgetShowsSingleInvitation() { + val invitations = listOf( + CourseInvitation(1L, 100L, "Introduction to Computer Science", 10L) + ) + + val uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = invitations + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Course Invitations (1)").assertIsDisplayed() + composeTestRule.onNodeWithText("Introduction to Computer Science").assertIsDisplayed() + composeTestRule.onNodeWithText("Accept").assertIsDisplayed() + composeTestRule.onNodeWithText("Decline").assertIsDisplayed() + } + + @Test + fun testWidgetShowsMultipleInvitations() { + val invitations = listOf( + CourseInvitation(1L, 100L, "Introduction to Computer Science", 10L), + CourseInvitation(2L, 200L, "Advanced Mathematics", 10L), + CourseInvitation(3L, 300L, "Art History 101", 10L) + ) + + val uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = invitations + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Course Invitations (3)").assertIsDisplayed() + composeTestRule.onNodeWithText("Introduction to Computer Science").assertIsDisplayed() + + // Swipe to second page + composeTestRule.onRoot().performTouchInput { + swipeLeft( + startX = centerX + (width / 4), + endX = centerX - (width / 4) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Advanced Mathematics").assertIsDisplayed() + + // Swipe to third page + composeTestRule.onRoot().performTouchInput { + swipeLeft( + startX = centerX + (width / 4), + endX = centerX - (width / 4) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Art History 101").assertIsDisplayed() + } + + @Test + fun testAcceptButtonCallsCallback() { + val invitations = listOf( + CourseInvitation(1L, 100L, "Test Course", 10L) + ) + + var acceptCalled = false + var acceptedInvitation: CourseInvitation? = null + + val uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = invitations, + onAcceptInvitation = { invitation -> + acceptCalled = true + acceptedInvitation = invitation + } + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onAllNodesWithText("Accept")[0].performClick() + + assert(acceptCalled) + assert(acceptedInvitation == invitations[0]) + } + + @Test + fun testDeclineButtonShowsConfirmationDialog() { + val invitations = listOf( + CourseInvitation(1L, 100L, "Test Course", 10L) + ) + + val uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = invitations + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onAllNodesWithText("Decline")[0].performClick() + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Decline Invitation").assertIsDisplayed() + composeTestRule.onNodeWithText("Are you sure you want to decline the invitation to Test Course?", substring = true).assertIsDisplayed() + } + + @Test + fun testDeclineConfirmationCallsCallback() { + val invitations = listOf( + CourseInvitation(1L, 100L, "Test Course", 10L) + ) + + var declineCalled = false + var declinedInvitation: CourseInvitation? = null + + val uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = invitations, + onDeclineInvitation = { invitation -> + declineCalled = true + declinedInvitation = invitation + } + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onAllNodesWithText("Decline")[0].performClick() + + composeTestRule.waitForIdle() + // Click the confirm button in the dialog + composeTestRule.onAllNodesWithText("Decline")[1].performClick() + + assert(declineCalled) + assert(declinedInvitation == invitations[0]) + } + + @Test + fun testDeclineCancelDismissesDialog() { + val invitations = listOf( + CourseInvitation(1L, 100L, "Test Course", 10L) + ) + + var declineCalled = false + + val uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = invitations, + onDeclineInvitation = { declineCalled = true } + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onAllNodesWithText("Decline")[0].performClick() + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Cancel").performClick() + + composeTestRule.waitForIdle() + // Dialog should be dismissed + composeTestRule.onNodeWithText("Decline Invitation").assertDoesNotExist() + assert(!declineCalled) + } + + @Test + fun testWidgetShowsPagerIndicatorForMultiplePages() { + val invitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L), + CourseInvitation(2L, 200L, "Course 2", 10L), + CourseInvitation(3L, 300L, "Course 3", 10L) + ) + + val uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = invitations + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 2 // This will create 2 pages (2 invitations on first page, 1 on second) + ) + } + + composeTestRule.waitForIdle() + // Pager indicator should be visible when there are multiple pages + // Note: We can't easily test the pager indicator visibility as it doesn't have a testTag + // but we can verify the content is displayed correctly + composeTestRule.onNodeWithText("Course 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Course 2").assertIsDisplayed() + } + + @Test + fun testWidgetHidesInvitationCardsProperly() { + val invitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L) + ) + + val uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = invitations + ) + + composeTestRule.setContent { + CourseInvitationsContent( + uiState = uiState, + columns = 1 + ) + } + + composeTestRule.waitForIdle() + // Should show exactly 2 buttons per invitation (Accept and Decline) + composeTestRule.onAllNodesWithText("Accept").assertCountEquals(1) + composeTestRule.onAllNodesWithText("Decline").assertCountEquals(1) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetConfigDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetConfigDaoTest.kt new file mode 100644 index 0000000000..55f41a5e19 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetConfigDaoTest.kt @@ -0,0 +1,90 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WidgetConfigDaoTest { + + private lateinit var database: WidgetDatabase + private lateinit var dao: WidgetConfigDao + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, WidgetDatabase::class.java).build() + dao = database.widgetConfigDao() + } + + @After + fun teardown() { + database.close() + } + + @Test + fun upsertConfig_insertsNewEntity() = runTest { + val entity = WidgetConfigEntity("widget1", """{"key": "value"}""") + + dao.upsertConfig(entity) + + val result = dao.observeConfig("widget1").first() + assertEquals("widget1", result?.widgetId) + assertEquals("""{"key": "value"}""", result?.configJson) + } + + @Test + fun upsertConfig_updatesExistingEntity() = runTest { + val entity1 = WidgetConfigEntity("widget1", """{"key": "value1"}""") + dao.upsertConfig(entity1) + + val entity2 = WidgetConfigEntity("widget1", """{"key": "value2"}""") + dao.upsertConfig(entity2) + + val result = dao.observeConfig("widget1").first() + assertEquals("""{"key": "value2"}""", result?.configJson) + } + + @Test + fun observeConfig_returnsNullForNonExistent() = runTest { + val result = dao.observeConfig("nonexistent").first() + + assertNull(result) + } + + @Test + fun deleteConfig_removesEntity() = runTest { + val entity = WidgetConfigEntity("widget1", """{"key": "value"}""") + dao.upsertConfig(entity) + + dao.deleteConfig(entity) + + val result = dao.observeConfig("widget1").first() + assertNull(result) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabaseMigrationTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabaseMigrationTest.kt new file mode 100644 index 0000000000..b3b91c15a1 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabaseMigrationTest.kt @@ -0,0 +1,60 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +private const val TEST_DB = "widget-migration-test" +private val ALL_MIGRATIONS = widgetDatabaseMigrations + +@RunWith(AndroidJUnit4::class) +class WidgetDatabaseMigrationTest { + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + WidgetDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + @Throws(IOException::class) + fun migrateAll() { + // Create earliest version of the database. + helper.createDatabase(TEST_DB, 1).apply { + close() + } + + // Open latest version of the database. Room validates the schema once all migrations execute. + Room.databaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + WidgetDatabase::class.java, + TEST_DB + ) + .addMigrations(*ALL_MIGRATIONS).build().apply { + openHelper.writableDatabase.close() + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetMetadataDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetMetadataDaoTest.kt new file mode 100644 index 0000000000..4d6ef22f23 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetMetadataDaoTest.kt @@ -0,0 +1,137 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WidgetMetadataDaoTest { + + private lateinit var database: WidgetDatabase + private lateinit var dao: WidgetMetadataDao + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, WidgetDatabase::class.java).build() + dao = database.widgetMetadataDao() + } + + @After + fun teardown() { + database.close() + } + + @Test + fun upsertMetadata_insertsNewEntity() = runTest { + val entity = WidgetMetadataEntity("widget1", 0, true, true, false) + + dao.upsertMetadata(entity) + + val result = dao.observeMetadata("widget1").first() + assertEquals("widget1", result?.widgetId) + assertEquals(0, result?.position) + assertEquals(true, result?.isVisible) + assertEquals(true, result?.isEditable) + assertEquals(false, result?.isFullWidth) + } + + @Test + fun upsertMetadata_updatesExistingEntity() = runTest { + val entity1 = WidgetMetadataEntity("widget1", 0, true, true, false) + dao.upsertMetadata(entity1) + + val entity2 = WidgetMetadataEntity("widget1", 1, false, false, true) + dao.upsertMetadata(entity2) + + val result = dao.observeMetadata("widget1").first() + assertEquals(1, result?.position) + assertEquals(false, result?.isVisible) + assertEquals(false, result?.isEditable) + assertEquals(true, result?.isFullWidth) + } + + @Test + fun observeAllMetadata_returnsOrderedByPosition() = runTest { + dao.upsertMetadata(WidgetMetadataEntity("widget3", 2, true)) + dao.upsertMetadata(WidgetMetadataEntity("widget1", 0, true)) + dao.upsertMetadata(WidgetMetadataEntity("widget2", 1, true)) + + val result = dao.observeAllMetadata().first() + + assertEquals(3, result.size) + assertEquals("widget1", result[0].widgetId) + assertEquals("widget2", result[1].widgetId) + assertEquals("widget3", result[2].widgetId) + } + + @Test + fun observeMetadata_returnsNullForNonExistent() = runTest { + val result = dao.observeMetadata("nonexistent").first() + + assertNull(result) + } + + @Test + fun updatePosition_changesPosition() = runTest { + dao.upsertMetadata(WidgetMetadataEntity("widget1", 0, true)) + + dao.updatePosition("widget1", 5) + + val result = dao.observeMetadata("widget1").first() + assertEquals(5, result?.position) + } + + @Test + fun updateVisibility_changesVisibility() = runTest { + dao.upsertMetadata(WidgetMetadataEntity("widget1", 0, true)) + + dao.updateVisibility("widget1", false) + + val result = dao.observeMetadata("widget1").first() + assertEquals(false, result?.isVisible) + } + + @Test + fun deleteMetadata_removesEntity() = runTest { + val entity = WidgetMetadataEntity("widget1", 0, true) + dao.upsertMetadata(entity) + + dao.deleteMetadata(entity) + + val result = dao.observeMetadata("widget1").first() + assertNull(result) + } + + @Test + fun observeAllMetadata_returnsEmptyListWhenEmpty() = runTest { + val result = dao.observeAllMetadata().first() + + assertEquals(0, result.size) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidgetTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidgetTest.kt new file mode 100644 index 0000000000..ef82a01192 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidgetTest.kt @@ -0,0 +1,384 @@ +/* + * 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.pandautils.features.dashboard.widget.institutionalannouncements + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Date + +@RunWith(AndroidJUnit4::class) +class InstitutionalAnnouncementsWidgetTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testWidgetDoesNotShowWhenLoading() { + val uiState = InstitutionalAnnouncementsUiState( + loading = true, + error = false, + announcements = emptyList() + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements", substring = true).assertDoesNotExist() + } + + @Test + fun testWidgetDoesNotShowWhenError() { + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = true, + announcements = emptyList() + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements", substring = true).assertDoesNotExist() + } + + @Test + fun testWidgetDoesNotShowWhenNoAnnouncements() { + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = emptyList() + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements", substring = true).assertDoesNotExist() + } + + @Test + fun testWidgetShowsSingleAnnouncement() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Campus Maintenance", + message = "Campus will be closed for maintenance", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements (1)").assertIsDisplayed() + composeTestRule.onNodeWithText("Campus Maintenance").assertIsDisplayed() + composeTestRule.onNodeWithText("Test University").assertIsDisplayed() + } + + @Test + fun testWidgetShowsMultipleAnnouncements() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Campus Maintenance", + message = "Message 1", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2L, + subject = "New Semester", + message = "Message 2", + institutionName = "Test University", + startDate = Date(), + icon = "calendar", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 3L, + subject = "System Update", + message = "Message 3", + institutionName = "Test University", + startDate = Date(), + icon = "warning", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcements (3)").assertIsDisplayed() + composeTestRule.onNodeWithText("Campus Maintenance").assertIsDisplayed() + + // Swipe to second page + composeTestRule.onRoot().performTouchInput { + swipeLeft( + startX = centerX + (width / 4), + endX = centerX - (width / 4) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("New Semester").assertIsDisplayed() + + // Swipe to third page + composeTestRule.onRoot().performTouchInput { + swipeLeft( + startX = centerX + (width / 4), + endX = centerX - (width / 4) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("System Update").assertIsDisplayed() + } + + @Test + fun testAnnouncementCardClickCallsCallback() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Test Announcement", + message = "Test Message", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + var clickCalled = false + var clickedSubject: String? = null + var clickedMessage: String? = null + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { subject, message -> + clickCalled = true + clickedSubject = subject + clickedMessage = message + } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Test Announcement").performClick() + + assert(clickCalled) + assert(clickedSubject == "Test Announcement") + assert(clickedMessage == "Test Message") + } + + @Test + fun testWidgetShowsMultipleAnnouncementsInColumns() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Announcement 1", + message = "Message 1", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2L, + subject = "Announcement 2", + message = "Message 2", + institutionName = "Test University", + startDate = Date(), + icon = "calendar", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 3L, + subject = "Announcement 3", + message = "Message 3", + institutionName = "Test University", + startDate = Date(), + icon = "warning", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 2, // This will create 2 pages (2 announcements on first page, 1 on second) + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + // First page should show 2 announcements + composeTestRule.onNodeWithText("Announcement 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Announcement 2").assertIsDisplayed() + + // Swipe to second page + composeTestRule.onRoot().performTouchInput { + swipeLeft( + startX = centerX + (width / 4), + endX = centerX - (width / 4) + ) + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Announcement 3").assertIsDisplayed() + } + + @Test + fun testWidgetShowsAnnouncementWithoutDate() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "No Date Announcement", + message = "Message", + institutionName = "Test University", + startDate = null, + icon = "info", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("No Date Announcement").assertIsDisplayed() + composeTestRule.onNodeWithText("Test University").assertIsDisplayed() + } + + @Test + fun testWidgetShowsPagerIndicatorForMultiplePages() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Announcement 1", + message = "Message 1", + institutionName = "Test University", + startDate = Date(), + icon = "info", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2L, + subject = "Announcement 2", + message = "Message 2", + institutionName = "Test University", + startDate = Date(), + icon = "calendar", + logoUrl = "" + ) + ) + + val uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = announcements + ) + + composeTestRule.setContent { + InstitutionalAnnouncementsContent( + uiState = uiState, + columns = 1, // This will create 2 pages + onAnnouncementClick = { _, _ -> } + ) + } + + composeTestRule.waitForIdle() + // First announcement should be visible + composeTestRule.onNodeWithText("Announcement 1").assertIsDisplayed() + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/todolist/ToDoListScreenTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/todolist/ToDoListScreenTest.kt new file mode 100644 index 0000000000..6d3dc5c5b1 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/todolist/ToDoListScreenTest.kt @@ -0,0 +1,430 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Calendar +import java.util.Date + +@RunWith(AndroidJUnit4::class) +class ToDoListScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Before + fun setup() { + ContextKeeper.appContext = context + } + + @Test + fun loadingStateIsDisplayed() { + composeTestRule.setContent { + ToDoListContent( + uiState = createLoadingUiState(), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoListLoading").assertIsDisplayed() + } + + @Test + fun errorStateIsDisplayed() { + composeTestRule.setContent { + ToDoListContent( + uiState = createErrorUiState(), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoListError").assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.errorLoadingToDos)).assertIsDisplayed() + } + + @Test + fun emptyStateIsDisplayed() { + composeTestRule.setContent { + ToDoListContent( + uiState = createEmptyUiState(), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoListEmpty").assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.noToDosForNow)).assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.noToDosForNowSubtext)).assertIsDisplayed() + } + + @Test + fun itemsListIsDisplayedWhenDataExists() { + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoList").assertIsDisplayed() + composeTestRule.onNodeWithText("Test Assignment").assertIsDisplayed() + } + + @Test + fun multipleItemsAreDisplayed() { + val item1 = createToDoItem(id = "1", title = "Assignment 1") + val item2 = createToDoItem(id = "2", title = "Quiz 1") + val item3 = createToDoItem(id = "3", title = "Discussion 1") + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems( + items = listOf(item1, item2, item3) + ), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Assignment 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Quiz 1").assertIsDisplayed() + composeTestRule.onNodeWithText("Discussion 1").assertIsDisplayed() + } + + @Test + fun itemHasCheckbox() { + val item = createToDoItem(id = "1", title = "Test Assignment") + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoCheckbox_1").assertIsDisplayed() + } + + @Test + fun checkboxClickTriggersCallback() { + var clicked = false + val item = createToDoItem( + id = "1", + title = "Test Assignment", + onCheckboxToggle = { clicked = true } + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithTag("todoCheckbox_1").performClick() + + assertTrue(clicked) + } + + @Test + fun itemClickTriggersCallback() { + var clickedUrl: String? = null + val item = createToDoItem( + id = "1", + title = "Clickable Assignment", + htmlUrl = "https://example.com/assignments/1" + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = { url -> clickedUrl = url }, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Clickable Assignment").performClick() + + assertEquals("https://example.com/assignments/1", clickedUrl) + } + + @Test + fun dateBadgeIsDisplayedForFirstItemInGroup() { + val item = createToDoItem(id = "1", title = "Test Assignment") + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + // Date badge should be visible (checking for day of month "22") + composeTestRule.onNodeWithText("22").assertIsDisplayed() + } + + @Test + fun itemsGroupedByDateDisplayedCorrectly() { + val calendar = Calendar.getInstance() + val today = calendar.time + calendar.add(Calendar.DAY_OF_MONTH, 1) + val tomorrow = calendar.time + + val item1 = createToDoItem(id = "1", title = "Today Assignment", date = today) + val item2 = createToDoItem(id = "2", title = "Tomorrow Assignment", date = tomorrow) + + composeTestRule.setContent { + ToDoListContent( + uiState = ToDoListUiState( + itemsByDate = mapOf( + today to listOf(item1), + tomorrow to listOf(item2) + ) + ), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Today Assignment").assertIsDisplayed() + composeTestRule.onNodeWithText("Tomorrow Assignment").assertIsDisplayed() + } + + @Test + fun emptyStateDisplayedWhenAllItemsFilteredOut() { + val item = createToDoItem(id = "1", title = "Filtered Item") + + composeTestRule.setContent { + ToDoListContent( + uiState = ToDoListUiState( + itemsByDate = mapOf(Date() to listOf(item)), + removingItemIds = setOf("1") + ), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.noToDosForNow)).assertIsDisplayed() + composeTestRule.onNodeWithText("Filtered Item").assertIsNotDisplayed() + } + + @Test + fun itemsFilteredByRemovingItemIds() { + val item1 = createToDoItem(id = "1", title = "Visible Item") + val item2 = createToDoItem(id = "2", title = "Hidden Item") + + composeTestRule.setContent { + ToDoListContent( + uiState = ToDoListUiState( + itemsByDate = mapOf(Date() to listOf(item1, item2)), + removingItemIds = setOf("2") + ), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Visible Item").assertIsDisplayed() + composeTestRule.onNodeWithText("Hidden Item").assertIsNotDisplayed() + } + + @Test + fun checkedItemDisplaysCorrectly() { + val item = createToDoItem( + id = "1", + title = "Completed Assignment", + isChecked = true + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Completed Assignment").assertIsDisplayed() + composeTestRule.onNodeWithTag("todoCheckbox_1").assertIsDisplayed() + } + + @Test + fun itemWithTagDisplaysTag() { + val item = createToDoItem( + id = "1", + title = "Assignment with Tag", + tag = "Important Tag" + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Assignment with Tag").assertIsDisplayed() + composeTestRule.onNodeWithText("Important Tag").assertIsDisplayed() + } + + @Test + fun itemWithDateLabelDisplaysLabel() { + val item = createToDoItem( + id = "1", + title = "Assignment with Date", + dateLabel = "11:59 PM" + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Assignment with Date").assertIsDisplayed() + composeTestRule.onNodeWithText("11:59 PM").assertIsDisplayed() + } + + @Test + fun nonClickableItemDoesNotHaveClickAction() { + val item = createToDoItem( + id = "1", + title = "Non-clickable Item", + isClickable = false + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Non-clickable Item").assertIsDisplayed() + } + + @Test + fun clickableItemHasClickAction() { + val item = createToDoItem( + id = "1", + title = "Clickable Item", + isClickable = true, + htmlUrl = "https://example.com" + ) + + composeTestRule.setContent { + ToDoListContent( + uiState = createUiStateWithItems(items = listOf(item)), + onOpenToDoItem = {}, + onDateClick = {} + ) + } + + composeTestRule.onNodeWithText("Clickable Item").assertIsDisplayed().assertHasClickAction() + } + + // Helper functions to create test data + + private fun createLoadingUiState(): ToDoListUiState { + return ToDoListUiState( + isLoading = true + ) + } + + private fun createErrorUiState(): ToDoListUiState { + return ToDoListUiState( + isError = true + ) + } + + private fun createEmptyUiState(): ToDoListUiState { + return ToDoListUiState( + itemsByDate = emptyMap() + ) + } + + private fun createUiStateWithItems( + items: List = listOf(createToDoItem()) + ): ToDoListUiState { + return ToDoListUiState( + itemsByDate = mapOf(Date() to items) + ) + } + + private fun createToDoItem( + id: String = "1", + title: String = "Test Assignment", + date: Date = Calendar.getInstance().apply { set(2024, 9, 22, 11, 59) }.time, + dateLabel: String? = "11:59 AM", + contextLabel: String = "Test Course", + canvasContext: CanvasContext = CanvasContext.defaultCanvasContext(), + itemType: ToDoItemType = ToDoItemType.ASSIGNMENT, + iconRes: Int = R.drawable.ic_assignment, + isChecked: Boolean = false, + isClickable: Boolean = true, + htmlUrl: String? = "https://example.com/assignments/$id", + tag: String? = null, + onCheckboxToggle: (Boolean) -> Unit = {}, + onSwipeToDone: () -> Unit = {} + ): ToDoItemUiState { + return ToDoItemUiState( + id = id, + title = title, + date = date, + dateLabel = dateLabel, + contextLabel = contextLabel, + canvasContext = canvasContext, + itemType = itemType, + iconRes = iconRes, + isChecked = isChecked, + isClickable = isClickable, + htmlUrl = htmlUrl, + tag = tag, + onCheckboxToggle = onCheckboxToggle, + onSwipeToDone = onSwipeToDone + ) + } +} diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterScreenTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterScreenTest.kt new file mode 100644 index 0000000000..626bb6644e --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterScreenTest.kt @@ -0,0 +1,369 @@ +/* + * Copyright (C) 2025 - 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.todolist.filter + +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.instructure.pandautils.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ToDoFilterScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun assertSectionHeadersAreVisible() { + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState() + ) + } + + composeTestRule.onNodeWithTag("ToDoFilterContent").performScrollToNode(hasText(context.getString(R.string.todoFilterVisibleItems))) + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterVisibleItems)).assertIsDisplayed() + composeTestRule.onNodeWithTag("ToDoFilterContent").performScrollToNode(hasText(context.getString(R.string.todoFilterShowTasksFrom))) + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowTasksFrom)).assertIsDisplayed() + composeTestRule.onNodeWithTag("ToDoFilterContent").performScrollToNode(hasText(context.getString(R.string.todoFilterShowTasksUntil))) + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowTasksUntil)).assertIsDisplayed() + } + + @Test + fun assertCheckboxItemsAreVisible() { + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState() + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowPersonalToDos)).assertIsDisplayed().assertHasClickAction() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCalendarEvents)).assertIsDisplayed().assertHasClickAction() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCompleted)).assertIsDisplayed().assertHasClickAction() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterFavoriteCoursesOnly)).assertIsDisplayed().assertHasClickAction() + } + + @Test + fun assertCheckboxStates() { + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState( + checkboxStates = listOf(true, false, true, false) + ) + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowPersonalToDos)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCalendarEvents)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCompleted)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterFavoriteCoursesOnly)) + .assertIsDisplayed() + } + + @Test + fun assertAllCheckboxesUnchecked() { + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState( + checkboxStates = listOf(false, false, false, false) + ) + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowPersonalToDos)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCalendarEvents)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCompleted)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterFavoriteCoursesOnly)) + .assertIsDisplayed() + } + + @Test + fun assertAllCheckboxesChecked() { + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState( + checkboxStates = listOf(true, true, true, true) + ) + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowPersonalToDos)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCalendarEvents)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCompleted)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterFavoriteCoursesOnly)) + .assertIsDisplayed() + } + + @Test + fun assertPastDateOptionsAreVisible() { + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState() + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterFourWeeks)).assertIsDisplayed().assertHasClickAction() + composeTestRule.onNodeWithText("From 7 Oct").assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterThreeWeeks)).assertIsDisplayed().assertHasClickAction() + composeTestRule.onNodeWithText("From 14 Oct").assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterTwoWeeks)).assertIsDisplayed().assertHasClickAction() + composeTestRule.onNodeWithText("From 21 Oct").assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterLastWeek)).assertIsDisplayed().assertHasClickAction() + composeTestRule.onNodeWithText("From 28 Oct").assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterThisWeek)).assertIsDisplayed().assertHasClickAction() + // Note: "From 4 Nov" appears twice (once for THIS_WEEK, once for TODAY), so we check with allowMultiple + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterToday)).assertIsDisplayed().assertHasClickAction() + } + + @Test + fun assertPastDateOptionSelection() { + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState(selectedPastOption = DateRangeSelection.TWO_WEEKS) + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterFourWeeks)).assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterThreeWeeks)).assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterTwoWeeks)).assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterLastWeek)).assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterThisWeek)).assertIsDisplayed() + } + + @Test + fun clickingCheckboxTriggersCallback() { + var toggledValue: Boolean? = null + var callbackInvoked = false + + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState( + checkboxStates = listOf(false, false, false, false), + onCheckboxToggle = { index, checked -> + if (index == 0) { + toggledValue = checked + callbackInvoked = true + } + } + ) + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowPersonalToDos)).performClick() + + assert(callbackInvoked) + assertEquals(true, toggledValue) + } + + @Test + fun clickingPastDateOptionTriggersCallback() { + val selectedOptions = mutableListOf() + + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState( + onPastDaysChanged = { selection -> + selectedOptions.add(selection) + } + ) + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterTwoWeeks)).performClick() + + assertEquals(DateRangeSelection.TWO_WEEKS, selectedOptions.last()) + } + + @Test + fun assertDateOptionsAreInCorrectOrder() { + val uiState = createDefaultUiState() + + composeTestRule.setContent { + ToDoFilterContent(uiState = uiState) + } + + // Verify past section header exists + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowTasksFrom)).assertIsDisplayed() + + composeTestRule.onNodeWithTag("ToDoFilterContent", useUnmergedTree = true) + .performScrollToNode(hasText(context.getString(R.string.todoFilterShowTasksUntil))) + + // Verify future section header exists + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowTasksUntil), useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun assertMultipleCheckboxesToggles() { + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState( + checkboxStates = listOf(true, false, true, true) + ) + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowPersonalToDos)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCalendarEvents)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterShowCompleted)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterFavoriteCoursesOnly)) + .assertIsDisplayed() + } + + @Test + fun assertBothDateSelectionsCanBeDifferent() { + composeTestRule.setContent { + ToDoFilterContent( + uiState = createDefaultUiState( + selectedPastOption = DateRangeSelection.FOUR_WEEKS, + selectedFutureOption = DateRangeSelection.TODAY + ) + ) + } + + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterFourWeeks)).assertIsDisplayed() + // There are two "Today" options (one for past, one for future), so we just check they exist + composeTestRule.onNodeWithText(context.getString(R.string.todoFilterToday)).assertIsDisplayed() + } + + // Helper function to create default UI state for tests + private fun createDefaultUiState( + checkboxStates: List = listOf(false, false, false, false), + selectedPastOption: DateRangeSelection = DateRangeSelection.ONE_WEEK, + selectedFutureOption: DateRangeSelection = DateRangeSelection.ONE_WEEK, + onCheckboxToggle: (Int, Boolean) -> Unit = { _, _ -> }, + onPastDaysChanged: (DateRangeSelection) -> Unit = {}, + onFutureDaysChanged: (DateRangeSelection) -> Unit = {} + ): ToDoFilterUiState { + return ToDoFilterUiState( + checkboxItems = listOf( + FilterCheckboxItem( + titleRes = R.string.todoFilterShowPersonalToDos, + checked = checkboxStates[0], + onToggle = { onCheckboxToggle(0, it) } + ), + FilterCheckboxItem( + titleRes = R.string.todoFilterShowCalendarEvents, + checked = checkboxStates[1], + onToggle = { onCheckboxToggle(1, it) } + ), + FilterCheckboxItem( + titleRes = R.string.todoFilterShowCompleted, + checked = checkboxStates[2], + onToggle = { onCheckboxToggle(2, it) } + ), + FilterCheckboxItem( + titleRes = R.string.todoFilterFavoriteCoursesOnly, + checked = checkboxStates[3], + onToggle = { onCheckboxToggle(3, it) } + ) + ), + pastDateOptions = listOf( + DateRangeOption( + selection = DateRangeSelection.FOUR_WEEKS, + labelText = context.getString(R.string.todoFilterFourWeeks), + dateText = "From 7 Oct" + ), + DateRangeOption( + selection = DateRangeSelection.THREE_WEEKS, + labelText = context.getString(R.string.todoFilterThreeWeeks), + dateText = "From 14 Oct" + ), + DateRangeOption( + selection = DateRangeSelection.TWO_WEEKS, + labelText = context.getString(R.string.todoFilterTwoWeeks), + dateText = "From 21 Oct" + ), + DateRangeOption( + selection = DateRangeSelection.ONE_WEEK, + labelText = context.getString(R.string.todoFilterLastWeek), + dateText = "From 28 Oct" + ), + DateRangeOption( + selection = DateRangeSelection.THIS_WEEK, + labelText = context.getString(R.string.todoFilterThisWeek), + dateText = "From 4 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.TODAY, + labelText = context.getString(R.string.todoFilterToday), + dateText = "From 4 Nov" + ) + ), + selectedPastOption = selectedPastOption, + futureDateOptions = listOf( + DateRangeOption( + selection = DateRangeSelection.TODAY, + labelText = context.getString(R.string.todoFilterToday), + dateText = "Until 4 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.THIS_WEEK, + labelText = context.getString(R.string.todoFilterThisWeek), + dateText = "Until 10 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.ONE_WEEK, + labelText = context.getString(R.string.todoFilterNextWeek), + dateText = "Until 17 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.TWO_WEEKS, + labelText = context.getString(R.string.todoFilterInTwoWeeks), + dateText = "Until 24 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.THREE_WEEKS, + labelText = context.getString(R.string.todoFilterInThreeWeeks), + dateText = "Until 1 Dec" + ), + DateRangeOption( + selection = DateRangeSelection.FOUR_WEEKS, + labelText = context.getString(R.string.todoFilterInFourWeeks), + dateText = "Until 8 Dec" + ) + ), + selectedFutureOption = selectedFutureOption, + onPastDaysChanged = onPastDaysChanged, + onFutureDaysChanged = onFutureDaysChanged + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ToDoFilterDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ToDoFilterDaoTest.kt new file mode 100644 index 0000000000..779209a083 --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/ToDoFilterDaoTest.kt @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2025 - 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.room.appdatabase.daos + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.pandautils.features.todolist.filter.DateRangeSelection +import com.instructure.pandautils.room.appdatabase.AppDatabase +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ToDoFilterDaoTest { + + private lateinit var db: AppDatabase + private lateinit var toDoFilterDao: ToDoFilterDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + toDoFilterDao = db.toDoFilterDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun insertAndFindByUser() = runTest { + val filter = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L, + personalTodos = true, + calendarEvents = true, + showCompleted = false, + favoriteCourses = false, + pastDateRange = DateRangeSelection.ONE_WEEK, + futureDateRange = DateRangeSelection.TWO_WEEKS + ) + + toDoFilterDao.insertOrUpdate(filter) + + val foundFilter = toDoFilterDao.findByUser("test.instructure.com", 123L) + + assertEquals(filter.copy(id = 1), foundFilter) + } + + @Test + fun findByUserReturnsNullWhenNoMatch() = runTest { + val filter = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L, + personalTodos = true + ) + + toDoFilterDao.insertOrUpdate(filter) + + val foundFilter = toDoFilterDao.findByUser("other.instructure.com", 123L) + + assertNull(foundFilter) + } + + @Test + fun insertReplacesExistingFilterWithSameId() = runTest { + val filter1 = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L, + personalTodos = true, + calendarEvents = false + ) + + val filter2 = filter1.copy( + id = 1, + userDomain = "other.instructure.com", + userId = 456L, + personalTodos = false, + calendarEvents = true + ) + + toDoFilterDao.insertOrUpdate(filter1) + toDoFilterDao.insertOrUpdate(filter2) + + val foundFilter1 = toDoFilterDao.findByUser("test.instructure.com", 123L) + val foundFilter2 = toDoFilterDao.findByUser("other.instructure.com", 456L) + + assertNull(foundFilter1) + assertEquals(filter2, foundFilter2) + } + + @Test + fun insertOrUpdateCreatesNewFilterWhenNoExisting() = runTest { + val filter = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L, + personalTodos = true, + showCompleted = true + ) + + toDoFilterDao.insertOrUpdate(filter) + + val foundFilter = toDoFilterDao.findByUser("test.instructure.com", 123L) + + assertEquals(filter.copy(id = 1), foundFilter) + } + + @Test + fun insertOrUpdateUpdatesExistingFilter() = runTest { + val filter1 = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L, + personalTodos = false, + calendarEvents = false, + showCompleted = false, + favoriteCourses = false, + pastDateRange = DateRangeSelection.ONE_WEEK, + futureDateRange = DateRangeSelection.ONE_WEEK + ) + + toDoFilterDao.insertOrUpdate(filter1) + + val filter2 = filter1.copy( + id = 1, + personalTodos = true, + calendarEvents = true, + showCompleted = true, + pastDateRange = DateRangeSelection.THREE_WEEKS + ) + + toDoFilterDao.insertOrUpdate(filter2) + + val foundFilter = toDoFilterDao.findByUser("test.instructure.com", 123L) + + assertEquals(filter2, foundFilter) + } + + @Test + fun deleteByUserRemovesFilter() = runTest { + val filter1 = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L, + personalTodos = true + ) + + val filter2 = ToDoFilterEntity( + userDomain = "other.instructure.com", + userId = 456L, + personalTodos = false + ) + + toDoFilterDao.insertOrUpdate(filter1) + toDoFilterDao.insertOrUpdate(filter2) + + toDoFilterDao.deleteByUser("test.instructure.com", 123L) + + val foundFilter1 = toDoFilterDao.findByUser("test.instructure.com", 123L) + val foundFilter2 = toDoFilterDao.findByUser("other.instructure.com", 456L) + + assertNull(foundFilter1) + assertEquals(filter2.copy(id = 2), foundFilter2) + } + + @Test + fun deleteByUserDoesNothingWhenNoMatch() = runTest { + val filter = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L, + personalTodos = true + ) + + toDoFilterDao.insertOrUpdate(filter) + + toDoFilterDao.deleteByUser("other.instructure.com", 999L) + + val foundFilter = toDoFilterDao.findByUser("test.instructure.com", 123L) + + assertEquals(filter.copy(id = 1), foundFilter) + } + + @Test + fun multipleFiltersForDifferentUsers() = runTest { + val filter1 = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L, + personalTodos = true, + pastDateRange = DateRangeSelection.ONE_WEEK + ) + + val filter2 = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 456L, + personalTodos = false, + pastDateRange = DateRangeSelection.TWO_WEEKS + ) + + val filter3 = ToDoFilterEntity( + userDomain = "other.instructure.com", + userId = 123L, + personalTodos = true, + pastDateRange = DateRangeSelection.THREE_WEEKS + ) + + toDoFilterDao.insertOrUpdate(filter1) + toDoFilterDao.insertOrUpdate(filter2) + toDoFilterDao.insertOrUpdate(filter3) + + val found1 = toDoFilterDao.findByUser("test.instructure.com", 123L) + val found2 = toDoFilterDao.findByUser("test.instructure.com", 456L) + val found3 = toDoFilterDao.findByUser("other.instructure.com", 123L) + + assertEquals(filter1.copy(id = 1), found1) + assertEquals(filter2.copy(id = 2), found2) + assertEquals(filter3.copy(id = 3), found3) + } + + @Test + fun testDefaultValues() = runTest { + val filter = ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L + ) + + toDoFilterDao.insertOrUpdate(filter) + + val foundFilter = toDoFilterDao.findByUser("test.instructure.com", 123L) + + assertEquals(false, foundFilter?.personalTodos) + assertEquals(false, foundFilter?.calendarEvents) + assertEquals(false, foundFilter?.showCompleted) + assertEquals(false, foundFilter?.favoriteCourses) + assertEquals(DateRangeSelection.FOUR_WEEKS, foundFilter?.pastDateRange) + assertEquals(DateRangeSelection.THIS_WEEK, foundFilter?.futureDateRange) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/studentdb/CreateSubmissionDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/studentdb/CreateSubmissionDaoTest.kt index 75d658054f..db02a2e65c 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/studentdb/CreateSubmissionDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/studentdb/CreateSubmissionDaoTest.kt @@ -17,6 +17,7 @@ package com.instructure.pandautils.room.studentdb import android.content.Context import android.database.sqlite.SQLiteConstraintException +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -28,12 +29,16 @@ import junit.framework.TestCase.assertNull import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CreateSubmissionDaoTest { + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + private lateinit var db: StudentDb private lateinit var dao: CreateSubmissionDao @@ -252,4 +257,23 @@ class CreateSubmissionDaoTest { val result2 = dao.findSubmissionById(3L) assertEquals(entities[2], result2) } + + @Test + fun testUpdateSubmissionState() = runTest { + val entity = CreateSubmissionEntity( + id = 1, + assignmentId = 1, + userId = 1, + errorFlag = false, + submissionType = "online_text_entry", + canvasContext = CanvasContext.defaultCanvasContext(), + ) + + dao.insert(entity) + + dao.updateSubmissionState(1, com.instructure.pandautils.room.studentdb.entities.SubmissionState.FAILED) + + val result = dao.findSubmissionById(1) + assertEquals(com.instructure.pandautils.room.studentdb.entities.SubmissionState.FAILED, result?.submissionState) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/SnackbarMessage.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/SnackbarMessage.kt new file mode 100644 index 0000000000..9318b9d50c --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/SnackbarMessage.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2025 - 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.compose + +data class SnackbarMessage( + val message: String, + val actionLabel: String? = null, + val action: (() -> Unit)? = null +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/CheckboxText.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/CheckboxText.kt index 1163f16195..a1d5d5dde4 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/CheckboxText.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/CheckboxText.kt @@ -18,7 +18,6 @@ package com.instructure.pandautils.compose.composables import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding import androidx.compose.material.Checkbox import androidx.compose.material.CheckboxDefaults import androidx.compose.material.Text @@ -28,8 +27,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.instructure.pandautils.R @@ -43,10 +45,26 @@ fun CheckboxText( subtitle: String? = null, testTag: String = "checkboxText" ) { + val fullContentDescription = if (subtitle != null) { + "$text, $subtitle" + } else { + text + } + + val stateDescriptionText = if (selected) { + stringResource(R.string.a11y_buttonSelectionSelected) + } else { + stringResource(R.string.a11y_buttonSelectionNotSelected) + } + Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .clickable { onCheckedChanged(!selected) } + .semantics(mergeDescendants = true) { + contentDescription = fullContentDescription + stateDescription = stateDescriptionText + } ) { Checkbox( checked = selected, diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/PagerIndicator.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/PagerIndicator.kt new file mode 100644 index 0000000000..cf0faa1765 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/PagerIndicator.kt @@ -0,0 +1,101 @@ +/* + * 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.pandautils.compose.composables + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A horizontal pager indicator that shows the current page position with animated transitions. + * Active page indicator expands to a pill shape while inactive indicators remain circular. + * + * @param pagerState The state of the pager to track + * @param modifier Modifier to be applied to the indicator row + * @param activeColor Color of the active page indicator + * @param inactiveColor Color of inactive page indicators + * @param indicatorHeight Height of the indicator dots/pills + * @param activeIndicatorWidth Width of the active (expanded) indicator + * @param inactiveIndicatorWidth Width of inactive (circular) indicators + * @param indicatorSpacing Spacing between individual indicators + * @param animationDurationMillis Duration of the expand/collapse animation in milliseconds + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PagerIndicator( + pagerState: PagerState, + modifier: Modifier = Modifier, + activeColor: Color = Color.Black, + inactiveColor: Color = Color.Black.copy(alpha = 0.4f), + indicatorHeight: Dp = 8.dp, + activeIndicatorWidth: Dp = 32.dp, + inactiveIndicatorWidth: Dp = 8.dp, + indicatorSpacing: Dp = 8.dp, + animationDurationMillis: Int = 300 +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + repeat(pagerState.pageCount) { index -> + val isActive = pagerState.currentPage == index + val width by animateDpAsState( + targetValue = if (isActive) activeIndicatorWidth else inactiveIndicatorWidth, + animationSpec = tween(durationMillis = animationDurationMillis), + label = "indicatorWidth" + ) + val color by animateFloatAsState( + targetValue = if (isActive) activeColor.alpha else inactiveColor.alpha, + animationSpec = tween(durationMillis = animationDurationMillis), + label = "indicatorAlpha" + ) + Box( + modifier = Modifier + .height(indicatorHeight) + .width(width) + .clip(if (isActive) RoundedCornerShape(4.dp) else CircleShape) + .background( + if (isActive) activeColor.copy(alpha = color) + else inactiveColor.copy(alpha = color) + ) + ) + if (index < pagerState.pageCount - 1) { + Spacer(modifier = Modifier.width(indicatorSpacing)) + } + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/RadioButtonText.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/RadioButtonText.kt index 4e05e23165..31faa67aa3 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/RadioButtonText.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/RadioButtonText.kt @@ -26,6 +26,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import com.instructure.pandautils.R @@ -39,8 +43,18 @@ fun RadioButtonText( onClick: () -> Unit, modifier: Modifier = Modifier ) { + val stateDescriptionText = if (selected) { + stringResource(R.string.a11y_buttonSelectionSelected) + } else { + stringResource(R.string.a11y_buttonSelectionNotSelected) + } Row ( - modifier = modifier.clickable { onClick() }, + modifier = modifier + .clickable { onClick() } + .semantics(mergeDescendants = true) { + contentDescription = text + stateDescription = stateDescriptionText + }, verticalAlignment = Alignment.CenterVertically ) { RadioButton( diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SearchBar.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SearchBar.kt index eecee20841..ae69e32b6f 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SearchBar.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SearchBar.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,6 +46,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.instructure.pandautils.R @@ -61,7 +63,8 @@ fun SearchBar( searchQuery: String = "", collapsable: Boolean = true, @DrawableRes hintIcon: Int? = null, - collapseOnSearch: Boolean = false + collapseOnSearch: Boolean = false, + onQueryChange: ((String) -> Unit)? = null ) { Row( modifier = modifier @@ -69,7 +72,9 @@ fun SearchBar( verticalAlignment = Alignment.CenterVertically ) { var expanded by remember { mutableStateOf(!collapsable) } - var query by remember { mutableStateOf(searchQuery) } + var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(searchQuery)) + } val keyboardController = LocalSoftwareKeyboardController.current val focusRequester = remember { FocusRequester() } @@ -103,7 +108,10 @@ fun SearchBar( .focusRequester(focusRequester), placeholder = { Text(placeholder) }, value = query, - onValueChange = { query = it }, + onValueChange = { + query = it + onQueryChange?.invoke(it.text) + }, singleLine = true, keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Search @@ -111,7 +119,7 @@ fun SearchBar( keyboardActions = KeyboardActions( onSearch = { keyboardController?.hide() - onSearch(query) + onSearch(query.text) if (collapseOnSearch) { expanded = false onExpand?.invoke(false) @@ -142,11 +150,11 @@ fun SearchBar( ) }, trailingIcon = { - if (query.isNotEmpty()) { + if (query.text.isNotEmpty()) { IconButton( modifier = Modifier.testTag("clearButton"), onClick = { - query = "" + query = TextFieldValue("") onClear?.invoke() }) { Icon( diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt index 502258936c..dcff8f4c1e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt @@ -48,12 +48,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course -import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R import com.instructure.pandautils.compose.CanvasTheme -import com.instructure.pandautils.utils.ThemePrefs -import com.instructure.pandautils.utils.color +import com.instructure.pandautils.utils.courseOrUserColor import com.instructure.pandautils.utils.isCourse import com.instructure.pandautils.utils.isGroup import com.instructure.pandautils.utils.isUser @@ -188,13 +186,7 @@ private fun SelectContextItem( modifier: Modifier = Modifier ) { val context = LocalContext.current - val color = Color( - if (canvasContext is User) { - ThemePrefs.brandColor - } else { - canvasContext.color - } - ) + val color = Color(canvasContext.courseOrUserColor) Row( modifier = modifier .defaultMinSize(minHeight = 54.dp) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepository.kt new file mode 100644 index 0000000000..a7f26dbf49 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2025 - 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.accountnotification + +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.DataResult + +interface AccountNotificationRepository { + suspend fun getAccountNotifications( + forceRefresh: Boolean + ): DataResult> + + suspend fun deleteAccountNotification( + accountNotificationId: Long + ): DataResult +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryImpl.kt new file mode 100644 index 0000000000..9a04f70862 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryImpl.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 - 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.accountnotification + +import com.instructure.canvasapi2.apis.AccountNotificationAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.DataResult + +class AccountNotificationRepositoryImpl( + private val accountNotificationApi: AccountNotificationAPI.AccountNotificationInterface +) : AccountNotificationRepository { + + override suspend fun getAccountNotifications( + forceRefresh: Boolean + ): DataResult> { + val params = RestParams( + isForceReadFromNetwork = forceRefresh, + usePerPageQueryParam = true + ) + return accountNotificationApi.getAccountNotifications( + params = params, + includePast = false, + showIsClosed = false + ) + } + + override suspend fun deleteAccountNotification( + accountNotificationId: Long + ): DataResult { + val params = RestParams() + return accountNotificationApi.deleteAccountNotification(accountNotificationId, params) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepository.kt new file mode 100644 index 0000000000..8ced92d40e --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepository.kt @@ -0,0 +1,8 @@ +package com.instructure.pandautils.data.repository.course + +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.utils.DataResult + +interface CourseRepository { + suspend fun getCourse(courseId: Long, forceRefresh: Boolean): DataResult +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt new file mode 100644 index 0000000000..2f48fabb51 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt @@ -0,0 +1,16 @@ +package com.instructure.pandautils.data.repository.course + +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.utils.DataResult + +class CourseRepositoryImpl( + private val courseApi: CourseAPI.CoursesInterface +) : CourseRepository { + + override suspend fun getCourse(courseId: Long, forceRefresh: Boolean): DataResult { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return courseApi.getCourse(courseId, params) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepository.kt new file mode 100644 index 0000000000..b479e609e8 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepository.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2025 - 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.enrollment + +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.utils.DataResult + +interface EnrollmentRepository { + suspend fun getSelfEnrollments( + types: List?, + states: List?, + forceRefresh: Boolean + ): DataResult> + + suspend fun handleInvitation( + courseId: Long, + enrollmentId: Long, + accept: Boolean + ): DataResult +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryImpl.kt new file mode 100644 index 0000000000..8b1dc91c51 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryImpl.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2025 - 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.enrollment + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.utils.DataResult + +class EnrollmentRepositoryImpl( + private val enrollmentApi: EnrollmentAPI.EnrollmentInterface +) : EnrollmentRepository { + + override suspend fun getSelfEnrollments( + types: List?, + states: List?, + forceRefresh: Boolean + ): DataResult> { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return enrollmentApi.getFirstPageSelfEnrollments(types, states, params) + } + + override suspend fun handleInvitation( + courseId: Long, + enrollmentId: Long, + accept: Boolean + ): DataResult { + val params = RestParams() + val action = if (accept) "accept" else "reject" + return enrollmentApi.handleInvite(courseId, enrollmentId, action, params) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepository.kt new file mode 100644 index 0000000000..351858777e --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepository.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025 - 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.user + +import com.instructure.canvasapi2.models.Account +import com.instructure.canvasapi2.utils.DataResult + +interface UserRepository { + suspend fun getAccount(forceRefresh: Boolean): DataResult +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepositoryImpl.kt new file mode 100644 index 0000000000..087efa204f --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/user/UserRepositoryImpl.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2025 - 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.user + +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Account +import com.instructure.canvasapi2.utils.DataResult + +class UserRepositoryImpl( + private val userApi: UserAPI.UsersInterface +) : UserRepository { + + override suspend fun getAccount(forceRefresh: Boolean): DataResult { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return userApi.getAccount(params) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt index dbea27770a..4c4b89934b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/ApplicationModule.kt @@ -52,6 +52,7 @@ import org.threeten.bp.Clock import java.util.Locale import java.util.TimeZone import javax.inject.Singleton +import kotlin.random.Random /** * Module that provides all the application scope dependencies, that are not related to other module. @@ -189,4 +190,9 @@ class ApplicationModule { fun provideFileCache(): FileCache { return FileCache } + + @Provides + fun provideRandom(): Random { + return Random.Default + } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt index 7e5a898bc9..2363bfdc1b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt @@ -13,6 +13,7 @@ import com.instructure.pandautils.room.appdatabase.daos.MediaCommentDao import com.instructure.pandautils.room.appdatabase.daos.PendingSubmissionCommentDao import com.instructure.pandautils.room.appdatabase.daos.ReminderDao import com.instructure.pandautils.room.appdatabase.daos.SubmissionCommentDao +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao import com.instructure.pandautils.room.assignment.list.daos.AssignmentListSelectedFiltersEntityDao import com.instructure.pandautils.room.calendar.CalendarFilterDatabase import com.instructure.pandautils.room.calendar.daos.CalendarFilterDao @@ -93,6 +94,12 @@ class DatabaseModule { return appDatabase.fileDownloadProgressDao() } + @Provides + @Singleton + fun provideToDoFilterDao(appDatabase: AppDatabase): ToDoFilterDao { + return appDatabase.toDoFilterDao() + } + @Provides @Singleton fun provideCalendarFilterDao(calendarFilterDatabase: CalendarFilterDatabase): CalendarFilterDao { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt new file mode 100644 index 0000000000..bd158dbfa8 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt @@ -0,0 +1,72 @@ +/* + * 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.pandautils.di + +import com.instructure.canvasapi2.apis.AccountNotificationAPI +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepository +import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepositoryImpl +import com.instructure.pandautils.data.repository.course.CourseRepository +import com.instructure.pandautils.data.repository.course.CourseRepositoryImpl +import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepository +import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepositoryImpl +import com.instructure.pandautils.data.repository.user.UserRepository +import com.instructure.pandautils.data.repository.user.UserRepositoryImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class RepositoryModule { + + @Provides + @Singleton + fun provideEnrollmentRepository( + enrollmentApi: EnrollmentAPI.EnrollmentInterface + ): EnrollmentRepository { + return EnrollmentRepositoryImpl(enrollmentApi) + } + + @Provides + @Singleton + fun provideCourseRepository( + courseApi: CourseAPI.CoursesInterface + ): CourseRepository { + return CourseRepositoryImpl(courseApi) + } + + @Provides + @Singleton + fun provideAccountNotificationRepository( + accountNotificationApi: AccountNotificationAPI.AccountNotificationInterface + ): AccountNotificationRepository { + return AccountNotificationRepositoryImpl(accountNotificationApi) + } + + @Provides + @Singleton + fun provideUserRepository( + userApi: UserAPI.UsersInterface + ): UserRepository { + return UserRepositoryImpl(userApi) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/models/accountnotification/InstitutionalAnnouncement.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/models/accountnotification/InstitutionalAnnouncement.kt new file mode 100644 index 0000000000..706985104a --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/models/accountnotification/InstitutionalAnnouncement.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2025 - 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.models.accountnotification + +import java.util.Date + +data class InstitutionalAnnouncement( + val id: Long, + val subject: String, + val message: String, + val institutionName: String, + val startDate: Date?, + val icon: String, + val logoUrl: String +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/models/enrollment/CourseInvitation.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/models/enrollment/CourseInvitation.kt new file mode 100644 index 0000000000..26a1e9b65d --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/models/enrollment/CourseInvitation.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025 - 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.models.enrollment + +data class CourseInvitation( + val enrollmentId: Long, + val courseId: Long, + val courseName: String, + val userId: Long +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseFlowUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseFlowUseCase.kt new file mode 100644 index 0000000000..00861f4145 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseFlowUseCase.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 - 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.pandautils.domain.usecase + +import kotlinx.coroutines.flow.Flow + +/** + * Base class for use cases that return a Flow of results. + * + * Use this for use cases that emit multiple values over time or need reactive updates. + * + * @param Params The type of parameters this use case accepts + * @param Result The type of result this use case emits + * + * Usage: + * ``` + * class ObserveUserUpdatesUseCase @Inject constructor( + * private val userRepository: UserRepository + * ) : BaseFlowUseCase>() { + * override fun execute(params: String): Flow> { + * return userRepository.observeUser(params) + * .map { UseCaseResult.Success(it) } + * .catch { emit(UseCaseResult.Error(it)) } + * } + * } + * + * // Collect results + * observeUserUpdatesUseCase("user123").collect { result -> + * when (result) { + * is UseCaseResult.Success -> // Handle success + * is UseCaseResult.Error -> // Handle error + * is UseCaseResult.Loading -> // Handle loading + * } + * } + * ``` + */ +abstract class BaseFlowUseCase { + abstract fun execute(params: Params): Flow + + operator fun invoke(params: Params): Flow = execute(params) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseUseCase.kt new file mode 100644 index 0000000000..9b86b2228e --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseUseCase.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 - 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.pandautils.domain.usecase + +/** + * Base class for use cases that execute suspending operations and return a single result. + * + * @param Params The type of parameters this use case accepts + * @param Result The type of result this use case returns + * + * Usage: + * ``` + * class GetUserUseCase @Inject constructor( + * private val userRepository: UserRepository + * ) : BaseUseCase() { + * override suspend fun execute(params: String): User { + * return userRepository.getUser(params) + * } + * } + * + * // Call with invoke operator + * val user = getUserUseCase("user123") + * ``` + */ +abstract class BaseUseCase { + abstract suspend fun execute(params: Params): Result + + suspend operator fun invoke(params: Params): Result = execute(params) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/UseCaseResult.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/UseCaseResult.kt new file mode 100644 index 0000000000..b1831eab10 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/UseCaseResult.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 - 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.pandautils.domain.usecase + +/** + * Represents the result of a use case operation. + * + * Can be in one of three states: + * - Success: Operation completed successfully with data + * - Error: Operation failed with an exception + * - Loading: Operation is in progress + * + * Usage: + * ``` + * when (result) { + * is UseCaseResult.Success -> { + * val data = result.data + * // Handle success + * } + * is UseCaseResult.Error -> { + * val exception = result.exception + * // Handle error + * } + * is UseCaseResult.Loading -> { + * // Handle loading state + * } + * } + * ``` + */ +sealed class UseCaseResult { + data class Success(val data: T) : UseCaseResult() + data class Error(val exception: Throwable) : UseCaseResult() + object Loading : UseCaseResult() +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCase.kt new file mode 100644 index 0000000000..02a24aa8bb --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCase.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 - 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.accountnotification + +import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepository +import com.instructure.pandautils.data.repository.user.UserRepository +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement +import com.instructure.pandautils.domain.usecase.BaseUseCase +import com.instructure.pandautils.utils.ThemePrefs +import javax.inject.Inject + +data class LoadInstitutionalAnnouncementsParams( + val forceRefresh: Boolean = false +) + +class LoadInstitutionalAnnouncementsUseCase @Inject constructor( + private val accountNotificationRepository: AccountNotificationRepository, + private val userRepository: UserRepository, + private val themePrefs: ThemePrefs +) : BaseUseCase>() { + + override suspend fun execute(params: LoadInstitutionalAnnouncementsParams): List { + val notifications = accountNotificationRepository.getAccountNotifications( + forceRefresh = params.forceRefresh + ).dataOrThrow + + val account = userRepository.getAccount(forceRefresh = params.forceRefresh).dataOrNull + val institutionName = account?.name.orEmpty() + val logoUrl = themePrefs.mobileLogoUrl + + return notifications + .sortedByDescending { it.startDate } + .take(5) + .map { notification -> + InstitutionalAnnouncement( + id = notification.id, + subject = notification.subject, + message = notification.message, + institutionName = institutionName, + startDate = notification.startDate, + icon = notification.icon, + logoUrl = logoUrl + ) + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/enrollment/HandleCourseInvitationUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/enrollment/HandleCourseInvitationUseCase.kt new file mode 100644 index 0000000000..d7c215dfd8 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/enrollment/HandleCourseInvitationUseCase.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2025 - 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.enrollment + +import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +data class HandleCourseInvitationParams( + val courseId: Long, + val enrollmentId: Long, + val accept: Boolean +) + +class HandleCourseInvitationUseCase @Inject constructor( + private val enrollmentRepository: EnrollmentRepository +) : BaseUseCase() { + + override suspend fun execute(params: HandleCourseInvitationParams) { + enrollmentRepository.handleInvitation( + courseId = params.courseId, + enrollmentId = params.enrollmentId, + accept = params.accept + ).dataOrThrow + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/enrollment/LoadCourseInvitationsUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/enrollment/LoadCourseInvitationsUseCase.kt new file mode 100644 index 0000000000..e102d6b271 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/enrollment/LoadCourseInvitationsUseCase.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 - 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.enrollment + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.pandautils.data.repository.course.CourseRepository +import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepository +import com.instructure.pandautils.domain.models.enrollment.CourseInvitation +import com.instructure.pandautils.domain.usecase.BaseUseCase +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject + +data class LoadCourseInvitationsParams( + val forceRefresh: Boolean = false +) + +class LoadCourseInvitationsUseCase @Inject constructor( + private val enrollmentRepository: EnrollmentRepository, + private val courseRepository: CourseRepository +) : BaseUseCase>() { + + override suspend fun execute(params: LoadCourseInvitationsParams): List { + val enrollments = enrollmentRepository.getSelfEnrollments( + types = null, + states = listOf(EnrollmentAPI.STATE_INVITED), + forceRefresh = params.forceRefresh + ).dataOrThrow + + return coroutineScope { + enrollments.map { enrollment -> + async { + val course = courseRepository.getCourse(enrollment.courseId, params.forceRefresh).dataOrThrow + CourseInvitation( + enrollmentId = enrollment.id, + courseId = enrollment.courseId, + courseName = course.name, + userId = enrollment.userId + ) + } + }.awaitAll() + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsSubmissionHandler.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsSubmissionHandler.kt index e0591f7171..35b4540d14 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsSubmissionHandler.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsSubmissionHandler.kt @@ -27,15 +27,19 @@ import java.io.File interface AssignmentDetailsSubmissionHandler { var isUploading: Boolean + var isFailed: Boolean + var lastSubmissionId: Long? var lastSubmissionAssignmentId: Long? var lastSubmissionSubmissionType: String? var lastSubmissionIsDraft: Boolean var lastSubmissionEntry: String? - fun addAssignmentSubmissionObserver(context: Context, assignmentId: Long, userId: Long, resources: Resources, data: MutableLiveData, refreshAssignment: () -> Unit) + fun addAssignmentSubmissionObserver(context: Context, assignmentId: Long, userId: Long, resources: Resources, data: MutableLiveData, refreshAssignment: () -> Unit, updateGradeCell: () -> Unit = {}) fun removeAssignmentSubmissionObserver() + suspend fun ensureSubmissionStateIsCurrent(assignmentId: Long, userId: Long) + fun uploadAudioSubmission(context: Context?, course: Course?, assignment: Assignment?, file: File?) fun getVideoUri(fragment: FragmentActivity): Uri? diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsViewModel.kt index 12b26fb34d..b2bc72dbe2 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsViewModel.kt @@ -66,6 +66,7 @@ import com.instructure.pandautils.mvvm.ViewState import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.utils.AssignmentUtils2 import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.isAllowedToSubmitWithOverrides import com.instructure.pandautils.utils.HtmlContentFormatter import com.instructure.pandautils.utils.getSubAssignmentSubmissionGrade import com.instructure.pandautils.utils.getSubAssignmentSubmissionStateLabel @@ -154,14 +155,17 @@ class AssignmentDetailsViewModel @Inject constructor( init { markSubmissionAsRead() + submissionHandler.addAssignmentSubmissionObserver( context, assignmentId, apiPrefs.user?.id.orDefault(), resources, _data, - ::refreshAssignment + ::refreshAssignment, + ::updateGradeCell ) + _state.postValue(ViewState.Loading) loadData() @@ -319,10 +323,12 @@ class AssignmentDetailsViewModel @Inject constructor( ) ) } + + submissionHandler.ensureSubmissionStateIsCurrent(assignmentId, apiPrefs.user?.id.orDefault()) + _data.postValue(getViewData(assignmentResult, hasDraft)) _state.postValue(ViewState.Success) - // Check if we need to auto-navigate to submission details from push notification submissionId?.let { subId -> val submission = assignmentResult.submission if (submission != null @@ -356,6 +362,7 @@ class AssignmentDetailsViewModel @Inject constructor( viewModelScope.launch { try { val assignmentResult = assignmentDetailsRepository.getAssignment(isObserver, assignmentId, courseId.orDefault(), true) + submissionHandler.ensureSubmissionStateIsCurrent(assignmentId, apiPrefs.user?.id.orDefault()) _data.postValue(getViewData(assignmentResult, submissionHandler.lastSubmissionIsDraft)) } catch (e: Exception) { _events.value = Event(AssignmentDetailAction.ShowToast(resources.getString(R.string.assignmentRefreshError))) @@ -363,6 +370,24 @@ class AssignmentDetailsViewModel @Inject constructor( } } + private fun updateGradeCell() { + val currentData = _data.value ?: return + val assignment = assignment ?: return + + currentData.selectedGradeCellViewData = GradeCellViewData.fromSubmission( + resources, + assignmentDetailsColorProvider.getContentColor(course.value), + assignmentDetailsColorProvider.submissionAndRubricLabelColor, + assignment, + assignment.submission, + restrictQuantitativeData, + uploading = submissionHandler.isUploading, + failed = submissionHandler.isFailed, + gradingScheme = gradingScheme + ) + currentData.notifyPropertyChanged(BR.selectedGradeCellViewData) + } + private suspend fun getViewData(assignment: Assignment, hasDraft: Boolean): AssignmentDetailsViewData { val points = if (restrictQuantitativeData) { "" @@ -464,11 +489,10 @@ class AssignmentDetailsViewModel @Inject constructor( // Observers shouldn't see the submit button OR if the course is soft concluded val submitVisible = when { isObserver -> false - !course.value?.isBetweenValidDateRange().orDefault() -> false assignment.submission?.excused.orDefault() -> false else -> when (assignment.turnInType) { - Assignment.TurnInType.QUIZ, Assignment.TurnInType.DISCUSSION -> true - Assignment.TurnInType.ONLINE, Assignment.TurnInType.EXTERNAL_TOOL -> assignment.isAllowedToSubmit + Assignment.TurnInType.QUIZ, Assignment.TurnInType.DISCUSSION -> course.value?.isBetweenValidDateRange().orDefault() + Assignment.TurnInType.ONLINE, Assignment.TurnInType.EXTERNAL_TOOL -> assignment.isAllowedToSubmitWithOverrides(course.value) else -> false } } @@ -559,6 +583,8 @@ class AssignmentDetailsViewModel @Inject constructor( assignment, assignment.submission, restrictQuantitativeData, + uploading = submissionHandler.isUploading, + failed = submissionHandler.isFailed, gradingScheme = gradingScheme ), dueDate = due, @@ -597,6 +623,20 @@ class AssignmentDetailsViewModel @Inject constructor( val attempt = _data.value?.attempts?.getOrNull(position)?.data val selectedSubmission = attempt?.submission this.selectedSubmission = selectedSubmission + + val isFirstAttempt = position == 0 + val hasActiveSubmissionState = submissionHandler.isUploading || submissionHandler.isFailed + val isUploading = if (isFirstAttempt && hasActiveSubmissionState) { + submissionHandler.isUploading + } else { + attempt?.isUploading.orDefault() + } + val isFailed = if (isFirstAttempt && hasActiveSubmissionState) { + submissionHandler.isFailed + } else { + attempt?.isFailed.orDefault() + } + _data.value?.selectedGradeCellViewData = GradeCellViewData.fromSubmission( resources, assignmentDetailsColorProvider.getContentColor(course.value), @@ -604,8 +644,8 @@ class AssignmentDetailsViewModel @Inject constructor( assignment, selectedSubmission, restrictQuantitativeData, - attempt?.isUploading.orDefault(), - attempt?.isFailed.orDefault(), + isUploading, + isFailed, gradingScheme ) _data.value?.notifyPropertyChanged(BR.selectedGradeCellViewData) @@ -616,17 +656,25 @@ class AssignmentDetailsViewModel @Inject constructor( } fun onGradeCellClicked() { - if (submissionHandler.isUploading) { + if (submissionHandler.isUploading || submissionHandler.isFailed) { when (submissionHandler.lastSubmissionSubmissionType) { - SubmissionType.ONLINE_TEXT_ENTRY.apiString -> onDraftClicked() + SubmissionType.ONLINE_TEXT_ENTRY.apiString -> { + postAction( + AssignmentDetailAction.NavigateToTextEntryScreen( + assignment?.name, + submissionHandler.lastSubmissionEntry, + isFailure = submissionHandler.isFailed + ) + ) + } SubmissionType.ONLINE_UPLOAD.apiString, SubmissionType.MEDIA_RECORDING.apiString -> postAction( - AssignmentDetailAction.NavigateToUploadStatusScreen(submissionHandler.lastSubmissionAssignmentId.orDefault()) + AssignmentDetailAction.NavigateToUploadStatusScreen(submissionHandler.lastSubmissionId.orDefault()) ) SubmissionType.ONLINE_URL.apiString -> postAction( AssignmentDetailAction.NavigateToUrlSubmissionScreen( assignment?.name, submissionHandler.lastSubmissionEntry, - submissionHandler.lastSubmissionIsDraft + isFailure = submissionHandler.isFailed ) ) } @@ -649,7 +697,7 @@ class AssignmentDetailsViewModel @Inject constructor( AssignmentDetailAction.NavigateToTextEntryScreen( assignment?.name, submissionHandler.lastSubmissionEntry, - submissionHandler.lastSubmissionIsDraft + isFailure = false ) ) } @@ -684,9 +732,11 @@ class AssignmentDetailsViewModel @Inject constructor( SubmissionType.STUDENT_ANNOTATION -> postAction(AssignmentDetailAction.NavigateToAnnotationSubmissionScreen(assignment)) SubmissionType.MEDIA_RECORDING -> postAction(AssignmentDetailAction.ShowMediaDialog(assignment)) SubmissionType.EXTERNAL_TOOL, SubmissionType.BASIC_LTI_LAUNCH -> { - externalLTITool.let { + if (externalLTITool != null) { Analytics.logEvent(AnalyticsEventConstants.ASSIGNMENT_LAUNCHLTI_SELECTED) - postAction(AssignmentDetailAction.NavigateToLtiLaunchScreen(assignment.name.orEmpty(), it, assignment.ltiToolType().openInternally)) + postAction(AssignmentDetailAction.NavigateToLtiLaunchScreen(assignment.name.orEmpty(), externalLTITool, assignment.ltiToolType().openInternally)) + } else { + postAction(AssignmentDetailAction.ShowToast(resources.getString(R.string.generalUnexpectedError))) } } else -> Unit diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/list/AssignmentListViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/list/AssignmentListViewModel.kt index 61d3826a12..5c07cdfa38 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/list/AssignmentListViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/list/AssignmentListViewModel.kt @@ -264,12 +264,12 @@ class AssignmentListViewModel @Inject constructor( AssignmentFilter.All -> filteredAssignments AssignmentFilter.NotYetSubmitted -> filteredAssignments.filter { assignment -> val parentNotSubmitted = !assignment.isSubmitted && assignment.isOnlineSubmissionType - val hasUnsubmittedCheckpoint = assignment.hasAnyCheckpointWithoutGrade() + val hasUnsubmittedCheckpoint = assignment.hasAnyCheckpointNotSubmitted() parentNotSubmitted || hasUnsubmittedCheckpoint } AssignmentFilter.ToBeGraded -> filteredAssignments.filter { assignment -> val parentToBeGraded = assignment.isSubmitted && !assignment.isGraded() && assignment.isOnlineSubmissionType - val hasCheckpointToBeGraded = assignment.hasAnyCheckpointWithoutGrade() + val hasCheckpointToBeGraded = assignment.hasAnyCheckpointToBeGraded() parentToBeGraded || (hasCheckpointToBeGraded && assignment.isOnlineSubmissionType) } AssignmentFilter.Graded -> filteredAssignments.filter { assignment -> @@ -280,7 +280,7 @@ class AssignmentListViewModel @Inject constructor( val notYetSubmitted = !assignment.isSubmitted && assignment.isOnlineSubmissionType val toBeGraded = assignment.isSubmitted && !assignment.isGraded() && assignment.isOnlineSubmissionType val graded = assignment.isGraded() && assignment.isOnlineSubmissionType - val hasCheckpointNotYetSubmitted = assignment.hasAnyCheckpointWithoutGrade() + val hasCheckpointNotYetSubmitted = assignment.hasAnyCheckpointNotSubmitted() val hasCheckpointGraded = assignment.hasAnyCheckpointWithGrade() notYetSubmitted || toBeGraded || graded || hasCheckpointNotYetSubmitted || hasCheckpointGraded @@ -464,14 +464,29 @@ class AssignmentListViewModel @Inject constructor( } } - private fun Assignment.hasAnyCheckpointWithoutGrade(): Boolean { + private fun Assignment.hasAnyCheckpointToBeGraded(): Boolean { return if (checkpoints.isNotEmpty()) { submission?.subAssignmentSubmissions?.let { submissions -> checkpoints.any { checkpoint -> val checkpointSubmission = submissions.find { it.subAssignmentTag == checkpoint.tag } - checkpointSubmission?.grade == null && checkpointSubmission?.customGradeStatusId == null + checkpointSubmission?.submittedAt != null && + checkpointSubmission.grade == null && + checkpointSubmission.customGradeStatusId == null } - } ?: true // If no submissions exist, all checkpoints are unsubmitted + }.orDefault() + } else { + false + } + } + + private fun Assignment.hasAnyCheckpointNotSubmitted(): Boolean { + return if (checkpoints.isNotEmpty()) { + submission?.subAssignmentSubmissions?.let { submissions -> + checkpoints.any { checkpoint -> + val checkpointSubmission = submissions.find { it.subAssignmentTag == checkpoint.tag } + checkpointSubmission?.submittedAt == null + } + } ?: true } else { false } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/list/composables/AssignmentListScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/list/composables/AssignmentListScreen.kt index 56cf0dd4a6..97e3ad8c75 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/list/composables/AssignmentListScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/assignments/list/composables/AssignmentListScreen.kt @@ -16,6 +16,7 @@ */ package com.instructure.pandautils.features.assignments.list.composables +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background @@ -112,6 +113,10 @@ fun AssignmentListScreen( screenActionHandler: (AssignmentListScreenEvent) -> Unit, listActionHandler: (GroupedListViewEvent) -> Unit ) { + BackHandler(enabled = state.screenOption == AssignmentListScreenOption.Filter) { + screenActionHandler(AssignmentListScreenEvent.CloseFilterScreen) + } + CanvasTheme { when (state.screenOption) { AssignmentListScreenOption.List -> { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/BaseCalendarFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/BaseCalendarFragment.kt index 1f6b5169ef..e1221f307d 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/BaseCalendarFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/BaseCalendarFragment.kt @@ -124,6 +124,8 @@ open class BaseCalendarFragment : BaseCanvasFragment(), NavigationCallbacks, Fra SharedCalendarAction.TodayButtonTapped -> viewModel.handleAction(CalendarAction.TodayTapped) + is SharedCalendarAction.SelectDay -> viewModel.handleAction(CalendarAction.DaySelected(action.date)) + else -> {} } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/CalendarScreenUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/CalendarScreenUiState.kt index 60546d6654..e1a6171cef 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/CalendarScreenUiState.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/CalendarScreenUiState.kt @@ -140,4 +140,7 @@ sealed class SharedCalendarAction(val delay: Long = 0L) { data object CloseEventScreen : SharedCalendarAction() data class TodayButtonVisible(val visible: Boolean) : SharedCalendarAction() data object TodayButtonTapped : SharedCalendarAction() + // Delay is needed to ensure fragment navigation completes and the fragment subscribes to the event before it's emitted + data class SelectDay(val date: LocalDate) : SharedCalendarAction(delay = 100) + data object RefreshToDoList : SharedCalendarAction(delay = 50) } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt index 894a8b9697..447df04416 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt @@ -65,7 +65,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R import com.instructure.pandautils.compose.composables.ErrorContent @@ -75,7 +74,7 @@ import com.instructure.pandautils.features.calendar.CalendarEventsPageUiState import com.instructure.pandautils.features.calendar.CalendarEventsUiState import com.instructure.pandautils.features.calendar.EventUiState import com.instructure.pandautils.utils.ThemePrefs -import com.instructure.pandautils.utils.color +import com.instructure.pandautils.utils.courseOrUserColor import com.jakewharton.threetenabp.AndroidThreeTen private const val PAGE_COUNT = 1000 @@ -184,11 +183,7 @@ fun CalendarEventsPage( @Composable fun CalendarEventItem(eventUiState: EventUiState, onEventClick: (Long) -> Unit, modifier: Modifier = Modifier) { - val contextColor = if (eventUiState.canvasContext is User) { - Color(ThemePrefs.brandColor) - } else { - Color(eventUiState.canvasContext.color) - } + val contextColor = Color(eventUiState.canvasContext.courseOrUserColor) Row( modifier .clickable { onEventClick(eventUiState.plannableId) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt index 292d524db2..dcc4b1e145 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt @@ -23,8 +23,7 @@ import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.R import com.instructure.pandautils.features.calendar.CalendarRepository import com.instructure.pandautils.room.calendar.entities.CalendarFilterEntity -import com.instructure.pandautils.utils.ThemePrefs -import com.instructure.pandautils.utils.color +import com.instructure.pandautils.utils.courseOrUserColor import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -89,7 +88,7 @@ class CalendarFilterViewModel @Inject constructor( } private fun createFilterItemsUiState(type: CanvasContext.Type) = canvasContexts[type]?.map { - val color = if (type == CanvasContext.Type.USER) ThemePrefs.brandColor else it.color + val color = it.courseOrUserColor CalendarFilterItemUiState(it.contextId, it.name.orEmpty(), contextIdFilters.contains(it.contextId), color) } ?: emptyList() diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/createupdate/CreateUpdateEventFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/createupdate/CreateUpdateEventFragment.kt index e425d2a332..7cab934975 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/createupdate/CreateUpdateEventFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/createupdate/CreateUpdateEventFragment.kt @@ -78,12 +78,14 @@ class CreateUpdateEventFragment : BaseCanvasFragment(), NavigationCallbacks, Fra navigateBack() sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshDays(action.days)) sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.CloseEventScreen) + sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshToDoList) } is CreateUpdateEventViewModelAction.RefreshCalendar -> { navigateBack() sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshCalendar) sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.CloseEventScreen) + sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshToDoList) } is CreateUpdateEventViewModelAction.NavigateBack -> navigateBack() diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/details/EventFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/details/EventFragment.kt index 07098e6b5a..64afae1aa6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/details/EventFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendarevent/details/EventFragment.kt @@ -154,10 +154,12 @@ class EventFragment : BaseCanvasFragment(), NavigationCallbacks, FragmentInterac is EventViewModelAction.RefreshCalendarDays -> { navigateBack() sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshDays(action.days)) + sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshToDoList) } is EventViewModelAction.RefreshCalendar -> { navigateBack() sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshCalendar) + sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshToDoList) } is EventViewModelAction.OnReminderAddClicked -> checkAlarmPermission() is EventViewModelAction.NavigateToComposeMessageScreen -> eventRouter.navigateToComposeMessageScreen(action.options) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/createupdate/CreateUpdateToDoFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/createupdate/CreateUpdateToDoFragment.kt index 7c0220b17b..3d377438e1 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/createupdate/CreateUpdateToDoFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/createupdate/CreateUpdateToDoFragment.kt @@ -76,6 +76,7 @@ class CreateUpdateToDoFragment : BaseCanvasFragment(), NavigationCallbacks, Frag navigateBack() sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.CloseToDoScreen) sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshDays(action.days)) + sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshToDoList) } is CreateUpdateToDoViewModelAction.NavigateBack -> navigateBack() diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/details/ToDoFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/details/ToDoFragment.kt index 6eaf9808e4..b1b1293ccb 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/details/ToDoFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendartodo/details/ToDoFragment.kt @@ -92,6 +92,7 @@ class ToDoFragment : BaseCanvasFragment(), NavigationCallbacks, FragmentInteract when (action) { is ToDoViewModelAction.RefreshCalendarDay -> { sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshDays(listOf(action.date))) + sharedEvents.sendEvent(lifecycleScope, SharedCalendarAction.RefreshToDoList) navigateBack() } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModel.kt index dcb22e0948..df596f691d 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModel.kt @@ -33,6 +33,8 @@ import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDas import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardGroupItemViewModel import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardHeaderViewModel import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardNoteItemViewModel +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.calendar.SharedCalendarAction import com.instructure.pandautils.mvvm.Event import com.instructure.pandautils.mvvm.ItemViewModel import com.instructure.pandautils.mvvm.ViewState @@ -46,7 +48,8 @@ class EditDashboardViewModel @Inject constructor( private val courseManager: CourseManager, private val groupManager: GroupManager, private val repository: EditDashboardRepository, - private val networkStateProvider: NetworkStateProvider + private val networkStateProvider: NetworkStateProvider, + private val calendarSharedEvents: CalendarSharedEvents ) : ViewModel() { val state: LiveData @@ -155,6 +158,7 @@ class EditDashboardViewModel @Inject constructor( addCourseToFavorites(action.itemViewModel) courseManager.addCourseToFavoritesAsync(action.itemViewModel.id).await().dataOrThrow _events.postValue(Event(EditDashboardItemAction.ShowSnackBar(R.string.added_to_dashboard))) + calendarSharedEvents.sendEvent(viewModelScope, SharedCalendarAction.RefreshToDoList) } catch (e: Exception) { Logger.d("Failed to select course: ${e.printStackTrace()}") removeCourseFromFavorites(action.itemViewModel) @@ -184,6 +188,7 @@ class EditDashboardViewModel @Inject constructor( removeCourseFromFavorites(action.itemViewModel) courseManager.removeCourseFromFavoritesAsync(action.itemViewModel.id).await().dataOrThrow _events.postValue(Event(EditDashboardItemAction.ShowSnackBar(R.string.removed_from_dashboard))) + calendarSharedEvents.sendEvent(viewModelScope, SharedCalendarAction.RefreshToDoList) } catch (e: Exception) { Logger.d("Failed to deselect course: ${e.printStackTrace()}") addCourseToFavorites(action.itemViewModel) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetConfig.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetConfig.kt new file mode 100644 index 0000000000..3bc6309448 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetConfig.kt @@ -0,0 +1,22 @@ +/* + * 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.pandautils.features.dashboard.widget + +interface WidgetConfig { + val widgetId: String + fun toJson(): String +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt new file mode 100644 index 0000000000..ee9e75f387 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt @@ -0,0 +1,31 @@ +/* + * 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.pandautils.features.dashboard.widget + +data class WidgetMetadata( + val id: String, + val position: Int, + val isVisible: Boolean, + val isEditable: Boolean = true, + val isFullWidth: Boolean = false +) { + companion object { + const val WIDGET_ID_COURSE_INVITATIONS = "course_invitations" + const val WIDGET_ID_INSTITUTIONAL_ANNOUNCEMENTS = "institutional_announcements" + const val WIDGET_ID_WELCOME = "welcome" + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsUiState.kt new file mode 100644 index 0000000000..83f3f13761 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsUiState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2025 - 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.dashboard.widget.courseinvitation + +import com.instructure.pandautils.compose.SnackbarMessage +import com.instructure.pandautils.domain.models.enrollment.CourseInvitation + +data class CourseInvitationsUiState( + val loading: Boolean = true, + val error: Boolean = false, + val invitations: List = emptyList(), + val snackbarMessage: SnackbarMessage? = null, + val onRefresh: () -> Unit = {}, + val onAcceptInvitation: (CourseInvitation) -> Unit = {}, + val onDeclineInvitation: (CourseInvitation) -> Unit = {}, + val onClearSnackbar: () -> Unit = {} +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsViewModel.kt new file mode 100644 index 0000000000..1f2155e5ab --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsViewModel.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2025 - 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.dashboard.widget.courseinvitation + +import android.content.res.Resources +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.SnackbarMessage +import com.instructure.pandautils.domain.models.enrollment.CourseInvitation +import com.instructure.pandautils.domain.usecase.enrollment.HandleCourseInvitationParams +import com.instructure.pandautils.domain.usecase.enrollment.HandleCourseInvitationUseCase +import com.instructure.pandautils.domain.usecase.enrollment.LoadCourseInvitationsParams +import com.instructure.pandautils.domain.usecase.enrollment.LoadCourseInvitationsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CourseInvitationsViewModel @Inject constructor( + private val loadCourseInvitationsUseCase: LoadCourseInvitationsUseCase, + private val handleCourseInvitationUseCase: HandleCourseInvitationUseCase, + private val resources: Resources +) : ViewModel() { + + private val _uiState = MutableStateFlow( + CourseInvitationsUiState( + onRefresh = ::loadInvitations, + onAcceptInvitation = ::acceptInvitation, + onDeclineInvitation = ::declineInvitation, + onClearSnackbar = ::clearSnackbar + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadInvitations() + } + + private fun loadInvitations() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = false) } + try { + val invitations = loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) + _uiState.update { + it.copy( + loading = false, + error = false, + invitations = invitations + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + loading = false, + error = true + ) + } + } + } + } + + private fun acceptInvitation(invitation: CourseInvitation) { + handleInvitation(invitation, accept = true) + } + + private fun declineInvitation(invitation: CourseInvitation) { + handleInvitation(invitation, accept = false) + } + + private fun clearSnackbar() { + _uiState.update { + it.copy( + snackbarMessage = null, + onClearSnackbar = ::clearSnackbar + ) + } + } + + private fun handleInvitation(invitation: CourseInvitation, accept: Boolean) { + viewModelScope.launch { + val optimisticInvitations = _uiState.value.invitations.filter { it.enrollmentId != invitation.enrollmentId } + _uiState.update { it.copy(invitations = optimisticInvitations) } + + try { + handleCourseInvitationUseCase( + HandleCourseInvitationParams( + courseId = invitation.courseId, + enrollmentId = invitation.enrollmentId, + accept = accept + ) + ) + val message = if (accept) { + resources.getString(R.string.courseInvitationAccepted, invitation.courseName) + } else { + resources.getString(R.string.courseInvitationDeclined, invitation.courseName) + } + _uiState.update { + it.copy( + snackbarMessage = SnackbarMessage(message = message) + ) + } + } catch (e: Exception) { + val restoredInvitations = (_uiState.value.invitations + invitation).sortedBy { it.enrollmentId } + _uiState.update { + it.copy( + invitations = restoredInvitations, + snackbarMessage = SnackbarMessage( + message = resources.getString(R.string.errorOccurred), + actionLabel = resources.getString(R.string.retry), + action = { handleInvitation(invitation, accept) } + ) + ) + } + } + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidget.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidget.kt new file mode 100644 index 0000000000..d3ecd080e8 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidget.kt @@ -0,0 +1,344 @@ +/* + * 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.pandautils.features.dashboard.widget.courseinvitation + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.launch +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.composables.PagerIndicator +import com.instructure.pandautils.domain.models.enrollment.CourseInvitation +import com.instructure.pandautils.utils.ThemePrefs +import kotlinx.coroutines.flow.SharedFlow + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CourseInvitationsWidget( + refreshSignal: SharedFlow, + columns: Int, + onShowSnackbar: (String, String?, (() -> Unit)?) -> Unit, + modifier: Modifier = Modifier +) { + val viewModel: CourseInvitationsViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(refreshSignal) { + refreshSignal.collect { + uiState.onRefresh() + } + } + + LaunchedEffect(uiState.snackbarMessage) { + uiState.snackbarMessage?.let { snackbarMessage -> + onShowSnackbar(snackbarMessage.message, snackbarMessage.actionLabel, snackbarMessage.action) + uiState.onClearSnackbar() + } + } + + CourseInvitationsContent( + modifier = modifier, + uiState = uiState, + columns = columns + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CourseInvitationsContent( + modifier: Modifier = Modifier, + uiState: CourseInvitationsUiState, + columns: Int +) { + if (uiState.loading || uiState.error || uiState.invitations.isEmpty()) { + return + } + + var invitationToDecline by remember { mutableStateOf(null) } + + val invitationPages = uiState.invitations.chunked(columns) + + Column(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + text = stringResource(R.string.courseInvitationsTitle, uiState.invitations.size), + fontSize = 14.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.Normal, + color = colorResource(R.color.textDarkest) + ) + + val pagerState = rememberPagerState(pageCount = { invitationPages.size }) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + pageSpacing = 8.dp, + contentPadding = PaddingValues(start = 16.dp, end = 24.dp), + beyondViewportPageCount = 1 + ) { page -> + val invitationsInPage = invitationPages[page] + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + invitationsInPage.forEach { invitation -> + InvitationCard( + invitation = invitation, + onAccept = { uiState.onAcceptInvitation(invitation) }, + onDecline = { invitationToDecline = invitation }, + modifier = Modifier.weight(1f) + ) + } + // Add empty spaces to maintain card width when there are fewer cards than columns + repeat(columns - invitationsInPage.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + + if (invitationPages.size > 1) { + PagerIndicator( + pagerState = pagerState, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 16.dp), + activeColor = colorResource(R.color.backgroundDarkest), + inactiveColor = colorResource(R.color.backgroundDarkest).copy(alpha = 0.4f) + ) + } else { + Spacer(modifier = Modifier.height(16.dp)) + } + } + + invitationToDecline?.let { invitation -> + AlertDialog( + onDismissRequest = { invitationToDecline = null }, + title = { + Text( + text = stringResource(R.string.courseInvitationDeclineConfirmTitle), + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.textDarkest) + ) + }, + text = { + Text( + text = stringResource( + R.string.courseInvitationDeclineConfirmMessage, + invitation.courseName + ), + fontSize = 16.sp, + color = colorResource(R.color.textDark) + ) + }, + confirmButton = { + TextButton( + onClick = { + uiState.onDeclineInvitation(invitation) + invitationToDecline = null + } + ) { + Text( + text = stringResource(R.string.declineCourseInvitation), + color = colorResource(R.color.textDanger) + ) + } + }, + dismissButton = { + TextButton(onClick = { invitationToDecline = null }) { + Text( + text = stringResource(android.R.string.cancel), + color = colorResource(R.color.textDark) + ) + } + }, + shape = RoundedCornerShape(8.dp), + containerColor = colorResource(R.color.backgroundLightest) + ) + } +} + +@Composable +private fun InvitationCard( + invitation: CourseInvitation, + onAccept: () -> Unit, + onDecline: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.backgroundLightestElevated) + ) + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 24.dp), + text = invitation.courseName, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + overflow = TextOverflow.Ellipsis, + color = colorResource(R.color.textDarkest), + maxLines = 2 + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = onAccept, + modifier = Modifier + .weight(1f) + .height(32.dp), + shape = RoundedCornerShape(100.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(ThemePrefs.brandColor), + contentColor = colorResource(R.color.textLightest) + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + focusedElevation = 0.dp + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp) + ) { + Text( + text = stringResource(R.string.acceptCourseInvitation), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + } + + OutlinedButton( + onClick = onDecline, + modifier = Modifier + .weight(1f) + .height(32.dp), + shape = RoundedCornerShape(100.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = colorResource(R.color.backgroundLightest), + contentColor = colorResource(R.color.textDarkest) + ), + border = BorderStroke( + 0.5.dp, + colorResource(R.color.borderMedium) + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp) + ) { + Text( + text = stringResource(R.string.declineCourseInvitation), + fontSize = 12.sp, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Preview( + showBackground = true, + backgroundColor = 0xFF0F1316, + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +fun CourseInvitationsContentPreview() { + ContextKeeper.appContext = LocalContext.current + CourseInvitationsContent( + uiState = CourseInvitationsUiState( + loading = false, + error = false, + invitations = listOf( + CourseInvitation( + enrollmentId = 1, + courseId = 1, + courseName = "Introduction to Computer Science", + userId = 1 + ), + CourseInvitation( + enrollmentId = 2, + courseId = 2, + courseName = "Advanced Mathematics", + userId = 1 + ), + CourseInvitation( + enrollmentId = 3, + courseId = 3, + courseName = "Art History 101", + userId = 1 + ) + ) + ), + columns = 1 + ) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetConfigDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetConfigDao.kt new file mode 100644 index 0000000000..28a9237d31 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetConfigDao.kt @@ -0,0 +1,36 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface WidgetConfigDao { + + @Upsert + suspend fun upsertConfig(config: WidgetConfigEntity) + + @Query("SELECT * FROM widget_config WHERE widgetId = :widgetId") + fun observeConfig(widgetId: String): Flow + + @Delete + suspend fun deleteConfig(config: WidgetConfigEntity) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetConfigEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetConfigEntity.kt new file mode 100644 index 0000000000..7540b80693 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetConfigEntity.kt @@ -0,0 +1,27 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "widget_config") +data class WidgetConfigEntity( + @PrimaryKey + val widgetId: String, + val configJson: String +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabase.kt new file mode 100644 index 0000000000..0bf25d21a5 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabase.kt @@ -0,0 +1,33 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [ + WidgetConfigEntity::class, + WidgetMetadataEntity::class + ], + version = 2 +) +abstract class WidgetDatabase : RoomDatabase() { + + abstract fun widgetConfigDao(): WidgetConfigDao + abstract fun widgetMetadataDao(): WidgetMetadataDao +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabaseMigrations.kt new file mode 100644 index 0000000000..6a1d37c950 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabaseMigrations.kt @@ -0,0 +1,27 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import com.instructure.pandautils.room.common.createMigration + +val widgetDatabaseMigrations = arrayOf( + + createMigration(1, 2) { database -> + database.execSQL("ALTER TABLE widget_metadata ADD COLUMN isEditable INTEGER NOT NULL DEFAULT 1") + database.execSQL("ALTER TABLE widget_metadata ADD COLUMN isFullWidth INTEGER NOT NULL DEFAULT 0") + } +) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabaseProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabaseProvider.kt new file mode 100644 index 0000000000..c54a2e489a --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetDatabaseProvider.kt @@ -0,0 +1,54 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import android.content.Context +import androidx.room.Room +import com.google.firebase.crashlytics.FirebaseCrashlytics + +private const val WIDGET_DB_PREFIX = "widget-db-" + +class WidgetDatabaseProvider( + private val context: Context, + private val firebaseCrashlytics: FirebaseCrashlytics +) { + + private val dbMap = mutableMapOf() + + fun getDatabase(userId: Long?): WidgetDatabase { + if (userId == null) { + firebaseCrashlytics.recordException(IllegalStateException("You can't access the widget database while logged out")) + return Room.databaseBuilder(context, WidgetDatabase::class.java, "dummy-widget-db") + .addMigrations(*widgetDatabaseMigrations) + .fallbackToDestructiveMigration() + .build() + } + + return dbMap.getOrPut(userId) { + Room.databaseBuilder(context, WidgetDatabase::class.java, "$WIDGET_DB_PREFIX$userId") + .addMigrations(*widgetDatabaseMigrations) + .fallbackToDestructiveMigration() + .build() + } + } + + fun clearDatabase(userId: Long) { + getDatabase(userId).clearAllTables() + dbMap.remove(userId) + context.deleteDatabase("$WIDGET_DB_PREFIX$userId") + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetMetadataDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetMetadataDao.kt new file mode 100644 index 0000000000..80d31f909b --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetMetadataDao.kt @@ -0,0 +1,45 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface WidgetMetadataDao { + + @Upsert + suspend fun upsertMetadata(metadata: WidgetMetadataEntity) + + @Query("SELECT * FROM widget_metadata WHERE widgetId = :widgetId") + fun observeMetadata(widgetId: String): Flow + + @Query("SELECT * FROM widget_metadata ORDER BY position ASC") + fun observeAllMetadata(): Flow> + + @Query("UPDATE widget_metadata SET position = :position WHERE widgetId = :widgetId") + suspend fun updatePosition(widgetId: String, position: Int) + + @Query("UPDATE widget_metadata SET isVisible = :isVisible WHERE widgetId = :widgetId") + suspend fun updateVisibility(widgetId: String, isVisible: Boolean) + + @Delete + suspend fun deleteMetadata(metadata: WidgetMetadataEntity) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetMetadataEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetMetadataEntity.kt new file mode 100644 index 0000000000..d94258a573 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/db/WidgetMetadataEntity.kt @@ -0,0 +1,30 @@ +/* + * 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.pandautils.features.dashboard.widget.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "widget_metadata") +data class WidgetMetadataEntity( + @PrimaryKey + val widgetId: String, + val position: Int, + val isVisible: Boolean, + val isEditable: Boolean = true, + val isFullWidth: Boolean = false +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/di/WidgetModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/di/WidgetModule.kt new file mode 100644 index 0000000000..475fce972e --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/di/WidgetModule.kt @@ -0,0 +1,76 @@ +/* + * 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.pandautils.features.dashboard.widget.di + +import android.content.Context +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.features.dashboard.widget.db.WidgetConfigDao +import com.instructure.pandautils.features.dashboard.widget.db.WidgetDatabase +import com.instructure.pandautils.features.dashboard.widget.db.WidgetDatabaseProvider +import com.instructure.pandautils.features.dashboard.widget.db.WidgetMetadataDao +import com.instructure.pandautils.features.dashboard.widget.repository.WidgetMetadataRepository +import com.instructure.pandautils.features.dashboard.widget.repository.WidgetMetadataRepositoryImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class WidgetModule { + + @Provides + @Singleton + fun provideWidgetDatabaseProvider( + @ApplicationContext context: Context, + firebaseCrashlytics: FirebaseCrashlytics + ): WidgetDatabaseProvider { + return WidgetDatabaseProvider(context, firebaseCrashlytics) + } + + @Provides + fun provideWidgetDatabase( + widgetDatabaseProvider: WidgetDatabaseProvider, + apiPrefs: ApiPrefs + ): WidgetDatabase { + val userId = if (apiPrefs.isMasquerading || apiPrefs.isMasqueradingFromQRCode) { + apiPrefs.masqueradeId + } else { + apiPrefs.user?.id + } + return widgetDatabaseProvider.getDatabase(userId) + } + + @Provides + fun provideWidgetConfigDao(database: WidgetDatabase): WidgetConfigDao { + return database.widgetConfigDao() + } + + @Provides + fun provideWidgetMetadataDao(database: WidgetDatabase): WidgetMetadataDao { + return database.widgetMetadataDao() + } + + @Provides + @Singleton + fun provideWidgetMetadataRepository(dao: WidgetMetadataDao): WidgetMetadataRepository { + return WidgetMetadataRepositoryImpl(dao) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsUiState.kt new file mode 100644 index 0000000000..21fc5ae9e9 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsUiState.kt @@ -0,0 +1,25 @@ +/* + * 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.pandautils.features.dashboard.widget.institutionalannouncements + +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement + +data class InstitutionalAnnouncementsUiState( + val loading: Boolean = true, + val error: Boolean = false, + val announcements: List = emptyList(), + val onRefresh: () -> Unit = {} +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModel.kt new file mode 100644 index 0000000000..f55c7ef6b0 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModel.kt @@ -0,0 +1,70 @@ +/* + * 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.pandautils.features.dashboard.widget.institutionalannouncements + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.pandautils.domain.usecase.accountnotification.LoadInstitutionalAnnouncementsParams +import com.instructure.pandautils.domain.usecase.accountnotification.LoadInstitutionalAnnouncementsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class InstitutionalAnnouncementsViewModel @Inject constructor( + private val loadInstitutionalAnnouncementsUseCase: LoadInstitutionalAnnouncementsUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow( + InstitutionalAnnouncementsUiState( + onRefresh = ::loadAnnouncements + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadAnnouncements() + } + + private fun loadAnnouncements() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = false) } + try { + val announcements = loadInstitutionalAnnouncementsUseCase( + LoadInstitutionalAnnouncementsParams(forceRefresh = true) + ) + _uiState.update { + it.copy( + loading = false, + error = false, + announcements = announcements + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + loading = false, + error = true + ) + } + } + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt new file mode 100644 index 0000000000..df568450b1 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt @@ -0,0 +1,343 @@ +/* + * 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.pandautils.features.dashboard.widget.institutionalannouncements + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.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.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import androidx.compose.ui.graphics.Color +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.composables.PagerIndicator +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement +import com.instructure.pandautils.utils.ThemePrefs +import kotlinx.coroutines.flow.SharedFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun InstitutionalAnnouncementsWidget( + refreshSignal: SharedFlow, + columns: Int, + onAnnouncementClick: (String, String) -> Unit, + modifier: Modifier = Modifier +) { + val viewModel: InstitutionalAnnouncementsViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(refreshSignal) { + refreshSignal.collect { + uiState.onRefresh() + } + } + + InstitutionalAnnouncementsContent( + modifier = modifier, + uiState = uiState, + columns = columns, + onAnnouncementClick = onAnnouncementClick + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun InstitutionalAnnouncementsContent( + modifier: Modifier = Modifier, + uiState: InstitutionalAnnouncementsUiState, + columns: Int, + onAnnouncementClick: (String, String) -> Unit +) { + if (uiState.loading || uiState.error || uiState.announcements.isEmpty()) { + return + } + + val announcementPages = uiState.announcements.chunked(columns) + + Column(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + text = stringResource(R.string.institutionalAnnouncementsTitle, uiState.announcements.size), + fontSize = 14.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.Normal, + color = colorResource(R.color.textDarkest) + ) + + val pagerState = rememberPagerState(pageCount = { announcementPages.size }) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + pageSpacing = 8.dp, + contentPadding = PaddingValues(start = 16.dp, end = 24.dp), + beyondViewportPageCount = 1 + ) { page -> + val announcementsInPage = announcementPages[page] + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + announcementsInPage.forEach { announcement -> + AnnouncementCard( + announcement = announcement, + onClick = { onAnnouncementClick(announcement.subject, announcement.message) }, + modifier = Modifier.weight(1f) + ) + } + repeat(columns - announcementsInPage.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + + if (announcementPages.size > 1) { + PagerIndicator( + pagerState = pagerState, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 16.dp), + activeColor = colorResource(R.color.backgroundDarkest), + inactiveColor = colorResource(R.color.backgroundDarkest).copy(alpha = 0.4f) + ) + } else { + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun AnnouncementCard( + announcement: InstitutionalAnnouncement, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.backgroundLightestElevated) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Box { + if (announcement.logoUrl.isNotEmpty()) { + GlideImage( + model = announcement.logoUrl, + contentDescription = announcement.institutionName, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Inside + ) + } else { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = Color(ThemePrefs.brandColor), + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = announcement.institutionName.take(3).uppercase(), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = colorResource(R.color.white) + ) + } + } + + Box( + modifier = Modifier + .size(16.dp) + .align(Alignment.TopStart) + .offset(x = (-8).dp, y = (-8).dp) + .background(colorResource(R.color.backgroundLightestElevated), CircleShape) + ) { + Icon( + painter = painterResource(id = getIconResource(announcement.icon)), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .align(Alignment.Center), + tint = colorResource( + when (announcement.icon) { + AccountNotification.ACCOUNT_NOTIFICATION_WARNING -> R.color.textWarning + AccountNotification.ACCOUNT_NOTIFICATION_ERROR -> R.color.textDanger + else -> R.color.textInfo + } + ) + ) + } + } + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = announcement.institutionName, + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + color = Color(ThemePrefs.brandColor), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + announcement.startDate?.let { startDate -> + Text( + text = formatDateTime(startDate), + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + color = colorResource(R.color.textDark), + maxLines = 1 + ) + } + + Text( + modifier = Modifier.padding(top = 4.dp), + text = announcement.subject, + fontSize = 16.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + overflow = TextOverflow.Ellipsis, + color = colorResource(R.color.textDarkest), + maxLines = 2 + ) + } + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_down), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = colorResource(R.color.textDark) + ) + } + } +} + +@Composable +private fun getIconResource(icon: String): Int { + return when (icon) { + AccountNotification.ACCOUNT_NOTIFICATION_WARNING -> R.drawable.ic_warning_solid + AccountNotification.ACCOUNT_NOTIFICATION_ERROR -> R.drawable.ic_warning_solid + AccountNotification.ACCOUNT_NOTIFICATION_CALENDAR -> R.drawable.ic_calendar_solid + AccountNotification.ACCOUNT_NOTIFICATION_QUESTION -> R.drawable.ic_question_solid + else -> R.drawable.ic_info_solid + } +} + +private fun formatDateTime(date: Date): String { + val formatter = SimpleDateFormat("d MMM yyyy, h:mm a", Locale.getDefault()) + return formatter.format(date) +} + +@Preview(showBackground = true) +@Preview( + showBackground = true, + backgroundColor = 0xFF0F1316, + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +fun InstitutionalAnnouncementsContentPreview() { + ContextKeeper.appContext = LocalContext.current + InstitutionalAnnouncementsContent( + uiState = InstitutionalAnnouncementsUiState( + loading = false, + error = false, + announcements = listOf( + InstitutionalAnnouncement( + id = 1, + subject = "Back to School Ceremony Dress Code", + message = "Canvas will be offline for maintenance...", + institutionName = "Canvas College Sydney", + startDate = Date(), + icon = AccountNotification.ACCOUNT_NOTIFICATION_WARNING, + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2, + subject = "New Feature Release", + message = "We're excited to announce...", + institutionName = "Canvas College Sydney", + startDate = Date(), + icon = AccountNotification.ACCOUNT_NOTIFICATION_CALENDAR, + logoUrl = "" + ) + ) + ), + columns = 1, + onAnnouncementClick = { _, _ -> } + ) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/repository/BaseWidgetConfigRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/repository/BaseWidgetConfigRepository.kt new file mode 100644 index 0000000000..1d625d8f27 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/repository/BaseWidgetConfigRepository.kt @@ -0,0 +1,47 @@ +/* + * 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.pandautils.features.dashboard.widget.repository + +import com.instructure.pandautils.features.dashboard.widget.WidgetConfig +import com.instructure.pandautils.features.dashboard.widget.db.WidgetConfigDao +import com.instructure.pandautils.features.dashboard.widget.db.WidgetConfigEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +abstract class BaseWidgetConfigRepository( + private val dao: WidgetConfigDao +) : WidgetConfigRepository { + + abstract fun deserializeConfig(json: String): T? + abstract fun getDefaultConfig(): T + + override fun observeConfig(widgetId: String): Flow { + return dao.observeConfig(widgetId).map { entity -> + entity?.configJson?.let { deserializeConfig(it) } ?: getDefaultConfig() + } + } + + override suspend fun saveConfig(config: T) { + dao.upsertConfig(WidgetConfigEntity(config.widgetId, config.toJson())) + } + + override suspend fun deleteConfig(widgetId: String) { + val entity = dao.observeConfig(widgetId).first() + entity?.let { dao.deleteConfig(it) } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/repository/WidgetConfigRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/repository/WidgetConfigRepository.kt new file mode 100644 index 0000000000..9fc1a93590 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/repository/WidgetConfigRepository.kt @@ -0,0 +1,26 @@ +/* + * 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.pandautils.features.dashboard.widget.repository + +import com.instructure.pandautils.features.dashboard.widget.WidgetConfig +import kotlinx.coroutines.flow.Flow + +interface WidgetConfigRepository { + fun observeConfig(widgetId: String): Flow + suspend fun saveConfig(config: T) + suspend fun deleteConfig(widgetId: String) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/repository/WidgetMetadataRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/repository/WidgetMetadataRepository.kt new file mode 100644 index 0000000000..7b8547cd1e --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/repository/WidgetMetadataRepository.kt @@ -0,0 +1,72 @@ +/* + * 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.pandautils.features.dashboard.widget.repository + +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.db.WidgetMetadataDao +import com.instructure.pandautils.features.dashboard.widget.db.WidgetMetadataEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +interface WidgetMetadataRepository { + fun observeAllMetadata(): Flow> + suspend fun saveMetadata(metadata: WidgetMetadata) + suspend fun updatePosition(widgetId: String, position: Int) + suspend fun updateVisibility(widgetId: String, isVisible: Boolean) +} + +@Singleton +class WidgetMetadataRepositoryImpl @Inject constructor( + private val dao: WidgetMetadataDao +) : WidgetMetadataRepository { + + override fun observeAllMetadata(): Flow> { + return dao.observeAllMetadata().map { entities -> + entities.map { it.toMetadata() } + } + } + + override suspend fun saveMetadata(metadata: WidgetMetadata) { + dao.upsertMetadata(metadata.toEntity()) + } + + override suspend fun updatePosition(widgetId: String, position: Int) { + dao.updatePosition(widgetId, position) + } + + override suspend fun updateVisibility(widgetId: String, isVisible: Boolean) { + dao.updateVisibility(widgetId, isVisible) + } + + private fun WidgetMetadataEntity.toMetadata() = WidgetMetadata( + id = widgetId, + position = position, + isVisible = isVisible, + isEditable = isEditable, + isFullWidth = isFullWidth + ) + + private fun WidgetMetadata.toEntity() = WidgetMetadataEntity( + widgetId = id, + position = position, + isVisible = isVisible, + isEditable = isEditable, + isFullWidth = isFullWidth + ) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt new file mode 100644 index 0000000000..2b95cf1ad4 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt @@ -0,0 +1,61 @@ +/* + * 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.pandautils.features.dashboard.widget.usecase + +import com.instructure.pandautils.domain.usecase.BaseUseCase +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.repository.WidgetMetadataRepository +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class EnsureDefaultWidgetsUseCase @Inject constructor( + private val repository: WidgetMetadataRepository +) : BaseUseCase() { + + override suspend fun execute(params: Unit) { + val existingWidgets = repository.observeAllMetadata().first() + val existingWidgetIds = existingWidgets.map { it.id }.toSet() + + val defaultWidgets = listOf( + WidgetMetadata( + id = WidgetMetadata.WIDGET_ID_COURSE_INVITATIONS, + position = 0, + isVisible = true, + isEditable = false, + isFullWidth = true + ), + WidgetMetadata( + id = WidgetMetadata.WIDGET_ID_INSTITUTIONAL_ANNOUNCEMENTS, + position = 1, + isVisible = true, + isEditable = false, + isFullWidth = true + ), + WidgetMetadata( + id = WidgetMetadata.WIDGET_ID_WELCOME, + position = 2, + isVisible = true + ) + ) + + defaultWidgets.forEach { widget -> + if (widget.id !in existingWidgetIds) { + repository.saveMetadata(widget) + } + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/ObserveWidgetMetadataUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/ObserveWidgetMetadataUseCase.kt new file mode 100644 index 0000000000..3467a3f4b0 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/ObserveWidgetMetadataUseCase.kt @@ -0,0 +1,32 @@ +/* + * 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.pandautils.features.dashboard.widget.usecase + +import com.instructure.pandautils.domain.usecase.BaseFlowUseCase +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.repository.WidgetMetadataRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class ObserveWidgetMetadataUseCase @Inject constructor( + private val repository: WidgetMetadataRepository +) : BaseFlowUseCase>() { + + override fun execute(params: Unit): Flow> { + return repository.observeAllMetadata() + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/SaveWidgetMetadataUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/SaveWidgetMetadataUseCase.kt new file mode 100644 index 0000000000..c5ae87c212 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/SaveWidgetMetadataUseCase.kt @@ -0,0 +1,31 @@ +/* + * 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.pandautils.features.dashboard.widget.usecase + +import com.instructure.pandautils.domain.usecase.BaseUseCase +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.repository.WidgetMetadataRepository +import javax.inject.Inject + +class SaveWidgetMetadataUseCase @Inject constructor( + private val repository: WidgetMetadataRepository +) : BaseUseCase() { + + override suspend fun execute(params: WidgetMetadata) { + repository.saveMetadata(params) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeOfDay.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeOfDay.kt new file mode 100644 index 0000000000..a5956121d2 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeOfDay.kt @@ -0,0 +1,24 @@ +/* + * 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.pandautils.features.dashboard.widget.welcome + +enum class TimeOfDay { + MORNING, // 4am - 12pm + AFTERNOON, // 12pm - 5pm + EVENING, // 5pm - 9pm + NIGHT // 9pm - 4am +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeOfDayCalculator.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeOfDayCalculator.kt new file mode 100644 index 0000000000..c44097f1e9 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeOfDayCalculator.kt @@ -0,0 +1,31 @@ +/* + * 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.pandautils.features.dashboard.widget.welcome + +class TimeOfDayCalculator(private val timeProvider: TimeProvider) { + + fun getTimeOfDay(): TimeOfDay { + val hour = timeProvider.getCurrentHourOfDay() + return when { + hour < 4 -> TimeOfDay.NIGHT + hour < 12 -> TimeOfDay.MORNING + hour < 17 -> TimeOfDay.AFTERNOON + hour < 21 -> TimeOfDay.EVENING + else -> TimeOfDay.NIGHT + } + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeProvider.kt new file mode 100644 index 0000000000..7398c968bb --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeProvider.kt @@ -0,0 +1,29 @@ +/* + * 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.pandautils.features.dashboard.widget.welcome + +import java.util.Calendar + +interface TimeProvider { + fun getCurrentHourOfDay(): Int +} + +class SystemTimeProvider : TimeProvider { + override fun getCurrentHourOfDay(): Int { + return Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidget.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidget.kt new file mode 100644 index 0000000000..4f37e79851 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidget.kt @@ -0,0 +1,106 @@ +/* + * 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.pandautils.features.dashboard.widget.welcome + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.instructure.pandautils.R +import kotlinx.coroutines.flow.SharedFlow + +@Composable +fun WelcomeWidget( + refreshSignal: SharedFlow, + modifier: Modifier = Modifier +) { + val viewModel: WelcomeWidgetViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(refreshSignal) { + refreshSignal.collect { + viewModel.refresh() + } + } + + WelcomeContent( + modifier = modifier, + uiState = uiState + ) +} + +@Composable +private fun WelcomeContent( + modifier: Modifier = Modifier, + uiState: WelcomeWidgetUiState +) { + val contentDescriptionText = stringResource( + R.string.welcomeWidgetContentDescription, + uiState.greeting, + uiState.message + ) + + Column( + modifier = modifier + .padding(horizontal = 16.dp) + .semantics { contentDescription = contentDescriptionText } + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = uiState.greeting, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.textDarkest), + lineHeight = 29.sp + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + text = uiState.message, + fontSize = 14.sp, + color = colorResource(R.color.textDarkest), + lineHeight = 19.sp + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +fun WelcomeContentPreview() { + WelcomeContent( + uiState = WelcomeWidgetUiState( + greeting = "Good morning, Riley!", + message = "Every small step you take is progress. Keep going!" + ) + ) +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidgetUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidgetUiState.kt new file mode 100644 index 0000000000..91b28b47bd --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidgetUiState.kt @@ -0,0 +1,22 @@ +/* + * 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.pandautils.features.dashboard.widget.welcome + +data class WelcomeWidgetUiState( + val greeting: String = "", + val message: String = "" +) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidgetViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidgetViewModel.kt new file mode 100644 index 0000000000..56166dbea9 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidgetViewModel.kt @@ -0,0 +1,54 @@ +/* + * 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.pandautils.features.dashboard.widget.welcome + +import androidx.lifecycle.ViewModel +import com.instructure.pandautils.features.dashboard.widget.welcome.usecase.GetWelcomeGreetingUseCase +import com.instructure.pandautils.features.dashboard.widget.welcome.usecase.GetWelcomeMessageUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class WelcomeWidgetViewModel @Inject constructor( + private val getWelcomeGreetingUseCase: GetWelcomeGreetingUseCase, + private val getWelcomeMessageUseCase: GetWelcomeMessageUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(WelcomeWidgetUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadWelcomeContent() + } + + fun refresh() { + loadWelcomeContent() + } + + private fun loadWelcomeContent() { + _uiState.update { + it.copy( + greeting = getWelcomeGreetingUseCase(), + message = getWelcomeMessageUseCase() + ) + } + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/di/WelcomeWidgetModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/di/WelcomeWidgetModule.kt new file mode 100644 index 0000000000..d5e68f782a --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/di/WelcomeWidgetModule.kt @@ -0,0 +1,38 @@ +/* + * 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.pandautils.features.dashboard.widget.welcome.di + +import com.instructure.pandautils.features.dashboard.widget.welcome.SystemTimeProvider +import com.instructure.pandautils.features.dashboard.widget.welcome.TimeOfDayCalculator +import com.instructure.pandautils.features.dashboard.widget.welcome.TimeProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +class WelcomeWidgetModule { + + @Provides + fun provideTimeProvider(): TimeProvider = SystemTimeProvider() + + @Provides + fun provideTimeOfDayCalculator(timeProvider: TimeProvider): TimeOfDayCalculator { + return TimeOfDayCalculator(timeProvider) + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/usecase/GetWelcomeGreetingUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/usecase/GetWelcomeGreetingUseCase.kt new file mode 100644 index 0000000000..40937f7783 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/usecase/GetWelcomeGreetingUseCase.kt @@ -0,0 +1,52 @@ +/* + * 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.pandautils.features.dashboard.widget.welcome.usecase + +import android.content.res.Resources +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.R +import com.instructure.pandautils.features.dashboard.widget.welcome.TimeOfDay +import com.instructure.pandautils.features.dashboard.widget.welcome.TimeOfDayCalculator +import javax.inject.Inject + +class GetWelcomeGreetingUseCase @Inject constructor( + private val resources: Resources, + private val timeOfDayCalculator: TimeOfDayCalculator, + private val apiPrefs: ApiPrefs +) { + + operator fun invoke(): String { + val timeOfDay = timeOfDayCalculator.getTimeOfDay() + val firstName = apiPrefs.user?.shortName + + return if (!firstName.isNullOrBlank()) { + when (timeOfDay) { + TimeOfDay.MORNING -> resources.getString(R.string.welcomeGreetingMorningWithName, firstName) + TimeOfDay.AFTERNOON -> resources.getString(R.string.welcomeGreetingAfternoonWithName, firstName) + TimeOfDay.EVENING -> resources.getString(R.string.welcomeGreetingEveningWithName, firstName) + TimeOfDay.NIGHT -> resources.getString(R.string.welcomeGreetingNightWithName, firstName) + } + } else { + when (timeOfDay) { + TimeOfDay.MORNING -> resources.getString(R.string.welcomeGreetingMorning) + TimeOfDay.AFTERNOON -> resources.getString(R.string.welcomeGreetingAfternoon) + TimeOfDay.EVENING -> resources.getString(R.string.welcomeGreetingEvening) + TimeOfDay.NIGHT -> resources.getString(R.string.welcomeGreetingNight) + } + } + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/usecase/GetWelcomeMessageUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/usecase/GetWelcomeMessageUseCase.kt new file mode 100644 index 0000000000..924bcd6e02 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/usecase/GetWelcomeMessageUseCase.kt @@ -0,0 +1,46 @@ +/* + * 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.pandautils.features.dashboard.widget.welcome.usecase + +import android.content.res.Resources +import com.instructure.pandautils.R +import com.instructure.pandautils.features.dashboard.widget.welcome.TimeOfDay +import com.instructure.pandautils.features.dashboard.widget.welcome.TimeOfDayCalculator +import javax.inject.Inject +import kotlin.random.Random + +class GetWelcomeMessageUseCase @Inject constructor( + private val resources: Resources, + private val timeOfDayCalculator: TimeOfDayCalculator, + private val random: Random +) { + + operator fun invoke(): String { + val timeOfDay = timeOfDayCalculator.getTimeOfDay() + + val genericMessages = resources.getStringArray(R.array.welcomeMessagesGeneric) + val timeSpecificMessages = when (timeOfDay) { + TimeOfDay.MORNING -> resources.getStringArray(R.array.welcomeMessagesMorning) + TimeOfDay.AFTERNOON -> resources.getStringArray(R.array.welcomeMessagesAfternoon) + TimeOfDay.EVENING -> resources.getStringArray(R.array.welcomeMessagesEvening) + TimeOfDay.NIGHT -> resources.getStringArray(R.array.welcomeMessagesNight) + } + + val allMessages = genericMessages + timeSpecificMessages + return allMessages[random.nextInt(allMessages.size)] + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesScreen.kt index 0920811491..75871ed1e8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesScreen.kt @@ -21,6 +21,8 @@ import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith @@ -73,6 +75,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -100,6 +104,7 @@ import com.instructure.pandautils.compose.composables.ErrorContent import com.instructure.pandautils.compose.composables.FullScreenDialog import com.instructure.pandautils.compose.composables.GroupHeader import com.instructure.pandautils.compose.composables.Loading +import com.instructure.pandautils.compose.composables.SearchBar import com.instructure.pandautils.compose.composables.SubmissionState import com.instructure.pandautils.features.grades.gradepreferences.GradePreferencesScreen import com.instructure.pandautils.utils.DisplayGrade @@ -296,8 +301,46 @@ private fun GradesScreenContent( ) } + AnimatedVisibility( + visible = uiState.isSearchExpanded, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut() + ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(uiState.isSearchExpanded) { + if (uiState.isSearchExpanded) { + focusRequester.requestFocus() + } + } + + SearchBar( + icon = R.drawable.ic_search_white_24dp, + searchQuery = uiState.searchQuery, + tintColor = colorResource(R.color.textDarkest), + placeholder = stringResource(R.string.search), + collapsable = false, + onSearch = { + actionHandler(GradesAction.SearchQueryChanged(it)) + }, + onClear = { + actionHandler(GradesAction.SearchQueryChanged("")) + }, + onQueryChange = { + actionHandler(GradesAction.SearchQueryChanged(it)) + }, + modifier = Modifier + .testTag("searchField") + .focusRequester(focusRequester) + ) + } + if (uiState.items.isEmpty()) { - EmptyContent() + if (uiState.searchQuery.length >= 3) { + EmptySearchContent() + } else { + EmptyContent() + } } } @@ -422,6 +465,27 @@ private fun GradesCard( modifier = Modifier.size(24.dp) ) } + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable { + actionHandler(GradesAction.ToggleSearch) + } + .semantics { + role = Role.Button + }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_search_white_24dp), + contentDescription = stringResource(id = R.string.search), + tint = Color(userColor), + modifier = Modifier + .size(24.dp) + .testTag("searchIcon") + ) + } } } @@ -437,6 +501,18 @@ private fun EmptyContent() { ) } +@Composable +private fun EmptySearchContent() { + EmptyContent( + emptyTitle = stringResource(id = R.string.noMatchingAssignments), + emptyMessage = stringResource(id = R.string.noMatchingAssignmentsDescription), + imageRes = R.drawable.ic_panda_space, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp, horizontal = 16.dp) + ) +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun AssignmentItem( diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesUiState.kt index 9d665e52a6..0df96c8502 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesUiState.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesUiState.kt @@ -39,7 +39,9 @@ data class GradesUiState( val onlyGradedAssignmentsSwitchEnabled: Boolean = true, val gradeText: String = "", val isGradeLocked: Boolean = false, - val snackbarMessage: String? = null + val snackbarMessage: String? = null, + val searchQuery: String = "", + val isSearchExpanded: Boolean = false ) data class AssignmentGroupUiState( @@ -97,6 +99,8 @@ sealed class GradesAction { data class AssignmentClick(val id: Long) : GradesAction() data object SnackbarDismissed : GradesAction() data class ToggleCheckpointsExpanded(val assignmentId: Long) : GradesAction() + data object ToggleSearch : GradesAction() + data class SearchQueryChanged(val query: String) : GradesAction() } sealed class GradesViewModelAction { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesViewModel.kt index 52e7e1a39c..3023b15d02 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/grades/GradesViewModel.kt @@ -35,12 +35,14 @@ import com.instructure.pandautils.R import com.instructure.pandautils.compose.composables.DiscussionCheckpointUiState import com.instructure.pandautils.features.grades.gradepreferences.SortBy import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.debounce import com.instructure.pandautils.utils.filterHiddenAssignments import com.instructure.pandautils.utils.getAssignmentIcon import com.instructure.pandautils.utils.getGrade import com.instructure.pandautils.utils.getSubAssignmentSubmissionGrade import com.instructure.pandautils.utils.getSubAssignmentSubmissionStateLabel import com.instructure.pandautils.utils.getSubmissionStateLabel +import com.instructure.pandautils.utils.isAllowedToSubmitWithOverrides import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.orderedCheckpoints import dagger.hilt.android.lifecycle.HiltViewModel @@ -78,6 +80,16 @@ class GradesViewModel @Inject constructor( private var courseGrade: CourseGrade? = null private var customStatuses = listOf() + private var allItems = emptyList() + + private val debouncedSearch = debounce( + coroutineScope = viewModelScope + ) { query -> + val filteredItems = filterItems(allItems, query) + _uiState.update { + it.copy(items = filteredItems) + } + } init { loadGrades( @@ -121,16 +133,18 @@ class GradesViewModel @Inject constructor( courseGrade = repository.getCourseGrade(course, repository.studentId, enrollments, selectedGradingPeriod?.id) - val items = when (sortBy) { + allItems = when (sortBy) { SortBy.GROUP -> groupByAssignmentGroup(assignmentGroups) SortBy.DUE_DATE -> groupByDueDate(assignmentGroups) }.filter { it.assignments.isNotEmpty() } + val filteredItems = filterItems(allItems, _uiState.value.searchQuery) + _uiState.update { it.copy( - items = items, + items = filteredItems, isLoading = false, isRefreshing = false, gradePreferencesUiState = it.gradePreferencesUiState.copy( @@ -180,7 +194,7 @@ class GradesViewModel @Inject constructor( val dueAt = assignment.dueAt ?: assignment.orderedCheckpoints.firstOrNull { it.dueAt != null }?.dueAt val submission = assignment.submission val isWithoutGradedSubmission = submission == null || submission.isWithoutGradedSubmission - val isOverdue = assignment.isAllowedToSubmit && isWithoutGradedSubmission + val isOverdue = assignment.isAllowedToSubmitWithOverrides(course) && isWithoutGradedSubmission if (dueAt == null) { undated.add(assignment) } else { @@ -280,6 +294,21 @@ class GradesViewModel @Inject constructor( context.getString(R.string.due, "$dateText $timeText") } ?: context.getString(R.string.gradesNoDueDate) + private fun filterItems(items: List, query: String): List { + if (query.length < 3) return items + + return items.mapNotNull { group -> + val filteredAssignments = group.assignments.filter { assignment -> + assignment.name.contains(query, ignoreCase = true) + } + if (filteredAssignments.isEmpty()) { + null + } else { + group.copy(assignments = filteredAssignments) + } + } + } + fun handleAction(action: GradesAction) { when (action) { is GradesAction.Refresh -> { @@ -351,6 +380,24 @@ class GradesViewModel @Inject constructor( } _uiState.update { it.copy(items = items) } } + + is GradesAction.ToggleSearch -> { + val isExpanding = !uiState.value.isSearchExpanded + _uiState.update { + it.copy( + isSearchExpanded = isExpanding, + searchQuery = if (!isExpanding) "" else it.searchQuery, + items = if (!isExpanding) allItems else it.items + ) + } + } + + is GradesAction.SearchQueryChanged -> { + _uiState.update { + it.copy(searchQuery = action.query) + } + debouncedSearch(action.query) + } } } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxFragment.kt index bb96be8b5a..5f4244663a 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/list/InboxFragment.kt @@ -156,6 +156,7 @@ class InboxFragment : BaseCanvasFragment(), NavigationCallbacks, FragmentInterac private fun handleSharedViewModelAction(action: InboxSharedAction) { when (action) { is InboxSharedAction.RefreshListScreen -> { + viewModel.invalidateCache() viewModel.refresh() } else -> {} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCard.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCard.kt index 0be744708c..50cbc3ee03 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCard.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/inbox/utils/AttachmentCard.kt @@ -63,6 +63,7 @@ fun AttachmentCard( ) { val attachment = attachmentCardItem.attachment val status = attachmentCardItem.status + val isClickEnabled = status != AttachmentStatus.UPLOADING Card( backgroundColor = colorResource(id = com.instructure.pandares.R.color.backgroundLightest), @@ -70,7 +71,7 @@ fun AttachmentCard( shape = RoundedCornerShape(10.dp), modifier = modifier .testTag("attachment") - .clickable { onSelect() } + .clickable(enabled = isClickEnabled) { onSelect() } ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/AggregateProgressObserver.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/AggregateProgressObserver.kt index 5e574a747f..ab487fba23 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/AggregateProgressObserver.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/AggregateProgressObserver.kt @@ -75,10 +75,20 @@ class AggregateProgressObserver( init { GlobalScope.launch(Dispatchers.Main) { - courseProgressLiveData = courseSyncProgressDao.findAllLiveData() + courseProgressLiveData = try { + courseSyncProgressDao.findAllLiveData() + } catch (e: Exception) { + firebaseCrashlytics.recordException(e) + null + } courseProgressLiveData?.observeForever(courseProgressObserver) - fileProgressLiveData = fileSyncProgressDao.findAllLiveData() + fileProgressLiveData = try { + fileSyncProgressDao.findAllLiveData() + } catch (e: Exception) { + firebaseCrashlytics.recordException(e) + null + } fileProgressLiveData?.observeForever(fileProgressObserver) studioMediaProgressLiveData = try { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/SpeedGraderBottomSheet.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/SpeedGraderBottomSheet.kt index 497c4dff94..147e1d55a9 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/SpeedGraderBottomSheet.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/SpeedGraderBottomSheet.kt @@ -44,7 +44,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.window.core.layout.WindowWidthSizeClass import com.instructure.pandautils.R import com.instructure.pandautils.compose.CanvasTheme diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/content/SpeedGraderContentScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/content/SpeedGraderContentScreen.kt index 83f0d4e992..3b57935780 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/content/SpeedGraderContentScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/content/SpeedGraderContentScreen.kt @@ -72,7 +72,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.fragment.compose.AndroidFragment -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.viewmodel.compose.viewModel import androidx.window.core.layout.WindowWidthSizeClass import com.instructure.canvasapi2.utils.DateHelper @@ -308,7 +308,8 @@ private fun SubmissionStatus( is SubmissionStateLabel.Custom -> submissionStatus.label }, color = colorResource(id = submissionStatus.colorRes), - fontSize = 14.sp + fontSize = 14.sp, + modifier = Modifier.testTag("submissionStatusLabel") ) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/details/SpeedGraderDetailsScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/details/SpeedGraderDetailsScreen.kt index 0125347dd6..ab3df776dd 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/details/SpeedGraderDetailsScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/details/SpeedGraderDetailsScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.instructure.pandautils.R import com.instructure.pandautils.compose.composables.EmptyContent diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/SpeedGraderGradeScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/SpeedGraderGradeScreen.kt index e825ce1eb4..84e395cc61 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/SpeedGraderGradeScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/SpeedGraderGradeScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.instructure.pandautils.features.speedgrader.grade.comments.SpeedGraderCommentsScreen import com.instructure.pandautils.features.speedgrader.grade.grading.SpeedGraderGradingScreen import com.instructure.pandautils.features.speedgrader.grade.rubric.SpeedGraderRubricContent diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/comments/SpeedGraderCommentsScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/comments/SpeedGraderCommentsScreen.kt index 41d00f7f3b..f0f4b56a26 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/comments/SpeedGraderCommentsScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/comments/SpeedGraderCommentsScreen.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.instructure.pandautils.R import com.instructure.pandautils.compose.LocalCourseColor import com.instructure.pandautils.compose.composables.CanvasDivider diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/comments/commentlibrary/SpeedGraderCommentLibraryScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/comments/commentlibrary/SpeedGraderCommentLibraryScreen.kt index 0827475a76..4fb0695c90 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/comments/commentlibrary/SpeedGraderCommentLibraryScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/comments/commentlibrary/SpeedGraderCommentLibraryScreen.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.dialog diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/grading/SpeedGraderGradingScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/grading/SpeedGraderGradingScreen.kt index a3aba8f9d4..4ae2f05894 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/grading/SpeedGraderGradingScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/grading/SpeedGraderGradingScreen.kt @@ -61,7 +61,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.google.common.primitives.Floats.max import com.instructure.canvasapi2.models.GradingSchemeRow import com.instructure.canvasapi2.type.GradingType diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/rubric/SpeedGraderRubricScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/rubric/SpeedGraderRubricScreen.kt index e07d94873b..511145b25f 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/rubric/SpeedGraderRubricScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/speedgrader/grade/rubric/SpeedGraderRubricScreen.kt @@ -111,7 +111,9 @@ fun SpeedGraderRubricContent(uiState: SpeedGraderRubricUiState) { .padding(vertical = 12.dp, horizontal = 16.dp) ) { Text( - modifier = Modifier.padding(bottom = 14.dp).testTag("speedGraderRubricsLabel"), + modifier = Modifier + .padding(bottom = 14.dp) + .testTag("speedGraderRubricsLabel"), text = stringResource(R.string.rubricsTitle), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, @@ -316,8 +318,19 @@ private fun RubricCriterion( if (expanded) { CanvasDivider() } - - var enteredPoint by remember(assessment) { mutableStateOf(assessment?.points) } + var textFieldScore by remember(assessment) { + mutableStateOf(assessment?.points?.stringValueWithoutTrailingZeros.orEmpty()) + } + var enteredPoint by remember(assessment) { + mutableStateOf(assessment?.points) + } + LaunchedEffect(textFieldScore) { + val scoreAsDouble = textFieldScore.toDoubleOrNull() + if (scoreAsDouble != enteredPoint) { + enteredPoint = scoreAsDouble + onPointChanged(enteredPoint.orDefault(), rubricCriterion.id) + } + } Row( modifier = Modifier .fillMaxWidth() @@ -332,11 +345,8 @@ private fun RubricCriterion( Spacer(modifier = Modifier.weight(1f)) BasicTextFieldWithHintDecoration( modifier = Modifier.padding(end = 8.dp), - value = enteredPoint?.stringValueWithoutTrailingZeros.orEmpty(), - onValueChange = { point -> - enteredPoint = point.toDoubleOrNull() - onPointChanged(enteredPoint.orDefault(), rubricCriterion.id) - }, + value = textFieldScore, + onValueChange = { textFieldScore = it }, hint = stringResource(R.string.rubricScoreHint), textColor = LocalCourseColor.current, hintColor = colorResource(R.color.textPlaceholder), diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/submission/BaseSubmissionHelper.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/submission/BaseSubmissionHelper.kt index 0865098f07..55c5e4d550 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/submission/BaseSubmissionHelper.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/submission/BaseSubmissionHelper.kt @@ -46,7 +46,7 @@ abstract class BaseSubmissionHelper( text: String, attempt: Long = 1L, deleteBySubmissionTypeFilter: Assignment.SubmissionType? = null - ) { + ): Long { val dbSubmissionId = runBlocking { insertNewSubmission(assignmentId, deleteBySubmissionTypeFilter = deleteBySubmissionTypeFilter) { val entity = CreateSubmissionEntity( @@ -65,6 +65,7 @@ abstract class BaseSubmissionHelper( } startSubmissionWorker(SubmissionWorkerAction.TEXT_ENTRY, submissionId = dbSubmissionId) + return dbSubmissionId } suspend fun saveDraft( @@ -127,7 +128,7 @@ abstract class BaseSubmissionHelper( assignmentName: String?, url: String, attempt: Long = 1L - ) { + ): Long { val dbSubmissionId = runBlocking { insertNewSubmission(assignmentId) { val entity = CreateSubmissionEntity( @@ -145,6 +146,7 @@ abstract class BaseSubmissionHelper( } startSubmissionWorker(SubmissionWorkerAction.URL_ENTRY, submissionId = dbSubmissionId) + return dbSubmissionId } fun startFileSubmission( @@ -156,8 +158,8 @@ abstract class BaseSubmissionHelper( deleteBySubmissionTypeFilter: Assignment.SubmissionType? = null, attempt: Long = 1L - ) { - files.ifEmpty { return } // No need to upload files if we aren't given any + ): Long { + if (files.isEmpty()) return -1 val dbSubmissionId = runBlocking { insertNewSubmission(assignmentId, files, deleteBySubmissionTypeFilter) { @@ -177,6 +179,7 @@ abstract class BaseSubmissionHelper( } startSubmissionWorker(SubmissionWorkerAction.FILE_ENTRY, submissionId = dbSubmissionId) + return dbSubmissionId } fun retryFileSubmission(dbSubmissionId: Long) { @@ -199,7 +202,7 @@ abstract class BaseSubmissionHelper( attempt: Long = 1L, mediaType: String? = null, mediaSource: String? = null - ) { + ): Long { val file = File(mediaFilePath).let { FileSubmitObject( it.name, @@ -227,6 +230,7 @@ abstract class BaseSubmissionHelper( } startSubmissionWorker(SubmissionWorkerAction.MEDIA_ENTRY, submissionId = dbSubmissionId) + return dbSubmissionId } fun startStudioSubmission( @@ -235,7 +239,7 @@ abstract class BaseSubmissionHelper( assignmentName: String?, url: String, attempt: Long = 1L - ) { + ): Long { val dbSubmissionId = runBlocking { insertNewSubmission(assignmentId) { val entity = CreateSubmissionEntity( @@ -253,6 +257,7 @@ abstract class BaseSubmissionHelper( } startSubmissionWorker(SubmissionWorkerAction.STUDIO_ENTRY, submissionId = dbSubmissionId) + return dbSubmissionId } fun startCommentUpload( @@ -323,7 +328,7 @@ abstract class BaseSubmissionHelper( assignmentName: String?, annotatableAttachmentId: Long, attempt: Long = 1L - ) { + ): Long { val dbSubmissionId = runBlocking { insertNewSubmission(assignmentId) { val entity = CreateSubmissionEntity( @@ -341,6 +346,7 @@ abstract class BaseSubmissionHelper( } startSubmissionWorker(SubmissionWorkerAction.STUDENT_ANNOTATION, submissionId = dbSubmissionId) + return dbSubmissionId } private suspend fun insertNewSubmission( diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt new file mode 100644 index 0000000000..27fa16b6ec --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import org.threeten.bp.LocalDate + +/** + * Default implementation of ToDoListRouter with no-op implementations. + * Used when the app doesn't need custom routing behavior. + */ +class DefaultToDoListRouter : ToDoListRouter { + + override fun openNavigationDrawer() { + // No-op implementation + } + + override fun attachNavigationDrawer() { + // No-op implementation + } + + override fun openToDoItem(htmlUrl: String) { + // No-op implementation + } + + override fun openCalendar(date: LocalDate) { + // No-op implementation + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt new file mode 100644 index 0000000000..87d20371cf --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2025 - 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.todolist + +interface OnToDoCountChanged { + fun onToDoCountChanged(count: Int) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListFragment.kt new file mode 100644 index 0000000000..f1a255b00c --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListFragment.kt @@ -0,0 +1,97 @@ +package com.instructure.pandautils.features.todolist + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.utils.pageview.PageView +import com.instructure.interactions.FragmentInteractions +import com.instructure.interactions.Navigation +import com.instructure.interactions.router.Route +import com.instructure.pandautils.R +import com.instructure.pandautils.analytics.SCREEN_VIEW_TO_DO_LIST +import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.base.BaseCanvasFragment +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.interfaces.NavigationCallbacks +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.makeBundle +import com.instructure.pandautils.utils.toLocalDate +import com.instructure.pandautils.utils.withArgs +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@PageView +@ScreenView(SCREEN_VIEW_TO_DO_LIST) +@AndroidEntryPoint +class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationCallbacks { + + @Inject + lateinit var toDoListRouter: ToDoListRouter + + private var onToDoCountChanged: OnToDoCountChanged? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + onToDoCountChanged = context as? OnToDoCountChanged + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + applyTheme() + + return ComposeView(requireActivity()).apply { + setContent { + CanvasTheme { + ToDoListScreen( + navigationIconClick = { + toDoListRouter.openNavigationDrawer() + }, + openToDoItem = { itemId -> + toDoListRouter.openToDoItem(itemId) + }, + onToDoCountChanged = { count -> + onToDoCountChanged?.onToDoCountChanged(count) + }, + onDateClick = { date -> + toDoListRouter.openCalendar(date.toLocalDate()) + } + ) + } + } + } + } + + override val navigation: Navigation? + get() = activity as? Navigation + + override fun title(): String = getString(R.string.Todo) + + override fun applyTheme() { + ViewStyler.setStatusBarDark(requireActivity(), ThemePrefs.primaryColor) + toDoListRouter.attachNavigationDrawer() + } + + override fun getFragment(): Fragment = this + + override fun onHandleBackPressed() = false + + companion object { + fun makeRoute(canvasContext: CanvasContext): Route = Route(ToDoListFragment::class.java, canvasContext, Bundle()) + + private fun validateRoute(route: Route) = route.canvasContext != null + + fun newInstance(route: Route): ToDoListFragment? { + if (!validateRoute(route)) return null + return ToDoListFragment().withArgs(route.canvasContext!!.makeBundle()) + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt new file mode 100644 index 0000000000..a64ee2b5fd --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import com.instructure.canvasapi2.CanvasRestAdapter +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.PlannerAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.models.PlannerOverride +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.canvasapi2.utils.depaginate +import javax.inject.Inject + +class ToDoListRepository @Inject constructor( + private val plannerApi: PlannerAPI.PlannerInterface, + private val courseApi: CourseAPI.CoursesInterface +) { + suspend fun getPlannerItems( + startDate: String, + endDate: String, + forceRefresh: Boolean + ): DataResult> { + val restParams = RestParams(isForceReadFromNetwork = forceRefresh, usePerPageQueryParam = true) + return plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = restParams + ).depaginate { nextUrl -> + plannerApi.nextPagePlannerItems(nextUrl, restParams) + } + } + + suspend fun getCourses(forceRefresh: Boolean): DataResult> { + val restParams = RestParams(isForceReadFromNetwork = forceRefresh) + return courseApi.getFirstPageCourses(restParams).depaginate { nextUrl -> + courseApi.next(nextUrl, restParams) + } + } + + suspend fun updatePlannerOverride( + plannerOverrideId: Long, + markedComplete: Boolean + ): DataResult { + val restParams = RestParams(isForceReadFromNetwork = true) + return plannerApi.updatePlannerOverride( + plannerOverrideId = plannerOverrideId, + complete = markedComplete, + params = restParams + ) + } + + suspend fun createPlannerOverride( + plannableId: Long, + plannableType: PlannableType, + markedComplete: Boolean + ): DataResult { + val restParams = RestParams(isForceReadFromNetwork = true) + val override = PlannerOverride( + plannableId = plannableId, + plannableType = plannableType, + markedComplete = markedComplete + ) + return plannerApi.createPlannerOverride(override, restParams) + } + + fun invalidateCachedResponses() { + CanvasRestAdapter.clearCacheUrls("planner") + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRouter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRouter.kt new file mode 100644 index 0000000000..4f954ce766 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRouter.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import org.threeten.bp.LocalDate + +interface ToDoListRouter { + + fun openNavigationDrawer() + + fun attachNavigationDrawer() + + fun openToDoItem(htmlUrl: String) + + fun openCalendar(date: LocalDate) +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt new file mode 100644 index 0000000000..1ece0cacc7 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt @@ -0,0 +1,1076 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Snackbar +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.SnackbarResult +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +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.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.compose.composables.CanvasDivider +import com.instructure.pandautils.compose.composables.CanvasThemedAppBar +import com.instructure.pandautils.compose.composables.EmptyContent +import com.instructure.pandautils.compose.composables.ErrorContent +import com.instructure.pandautils.compose.composables.FullScreenDialog +import com.instructure.pandautils.compose.composables.Loading +import com.instructure.pandautils.compose.modifiers.conditional +import com.instructure.pandautils.features.todolist.filter.ToDoFilterScreen +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.courseOrUserColor +import com.instructure.pandautils.utils.performGestureHapticFeedback +import com.instructure.pandautils.utils.performToggleHapticFeedback +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.abs +import kotlin.math.roundToInt + +private const val SWIPE_THRESHOLD_DP = 150 + +private data class StickyHeaderState( + val item: ToDoItemUiState?, + val yOffset: Float, + val isVisible: Boolean +) + +private data class DateBadgeData( + val dayOfWeek: String, + val day: Int, + val month: String, + val isToday: Boolean, + val dateTextColor: Color +) + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ToDoListScreen( + navigationIconClick: () -> Unit, + openToDoItem: (String) -> Unit, + onToDoCountChanged: (Int) -> Unit, + onDateClick: (Date) -> Unit, + modifier: Modifier = Modifier +) { + val viewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + var showFilterScreen by rememberSaveable { mutableStateOf(false) } + + BackHandler(showFilterScreen) { + showFilterScreen = false + } + + LaunchedEffect(uiState.snackbarMessage) { + uiState.snackbarMessage?.let { message -> + snackbarHostState.showSnackbar(message) + uiState.onSnackbarDismissed() + } + } + + LaunchedEffect(uiState.confirmationSnackbarData) { + uiState.confirmationSnackbarData?.let { item -> + val messageRes = if (item.markedAsDone) { + R.string.todoMarkedAsDone + } else { + R.string.todoMarkedAsNotDone + } + val message = context.getString(messageRes, item.title) + val result = snackbarHostState.showSnackbar( + message = message, + actionLabel = context.getString(R.string.todoMarkedAsDoneSnackbarUndo), + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + uiState.onUndoMarkAsDoneUndoneAction() + } else { + uiState.onMarkedAsDoneSnackbarDismissed() + } + } + } + + LaunchedEffect(uiState.toDoCount) { + uiState.toDoCount?.let { count -> + onToDoCountChanged(count) + } + } + + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = uiState.onRefresh + ) + + Scaffold( + backgroundColor = colorResource(R.color.backgroundLightest), + topBar = { + CanvasThemedAppBar( + title = stringResource(id = R.string.Todo), + navIconRes = R.drawable.ic_hamburger, + navIconContentDescription = stringResource(id = R.string.navigation_drawer_open), + navigationActionClick = navigationIconClick, + actions = { + IconButton(onClick = { showFilterScreen = true }) { + Icon( + painter = painterResource( + id = if (uiState.isFilterApplied) R.drawable.ic_filter_filled else R.drawable.ic_filter_outline + ), + contentDescription = stringResource(id = R.string.a11y_contentDescriptionToDoFilter) + ) + } + } + ) + }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + actionColor = Color(ThemePrefs.textButtonColor) + ) + } + }, + modifier = modifier + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .pullRefresh(pullRefreshState) + ) { + ToDoListContent( + uiState = uiState, + onOpenToDoItem = openToDoItem, + onDateClick = onDateClick + ) + + PullRefreshIndicator( + refreshing = uiState.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + backgroundColor = colorResource(id = R.color.backgroundLightest), + contentColor = Color(ThemePrefs.brandColor) + ) + } + } + + if (showFilterScreen) { + FullScreenDialog( + onDismissRequest = { showFilterScreen = false } + ) { + // We need a NavHost here so the ViewModel would not persist across multiple openings of the filter screen + NavHost(rememberNavController(), startDestination = "filter") { + composable("filter") { + ToDoFilterScreen( + onFiltersChanged = { dateFiltersChanged -> + showFilterScreen = false + uiState.onFiltersChanged(dateFiltersChanged) + }, + onDismiss = { + showFilterScreen = false + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } +} + +@Composable +internal fun ToDoListContent( + uiState: ToDoListUiState, + onOpenToDoItem: (String) -> Unit, + onDateClick: (Date) -> Unit, + modifier: Modifier = Modifier +) { + // Filter out items that are being removed + val filteredItemsByDate = uiState.itemsByDate.mapValues { (_, items) -> + items.filter { it.id !in uiState.removingItemIds } + }.filterValues { it.isNotEmpty() } + + when { + uiState.isLoading -> { + Loading(modifier = modifier.fillMaxSize().testTag("todoListLoading")) + } + + uiState.isError -> { + ErrorContent( + errorMessage = stringResource(id = R.string.errorLoadingToDos), + retryClick = uiState.onRefresh, + modifier = modifier.fillMaxSize().testTag("todoListError") + ) + } + + filteredItemsByDate.isEmpty() -> { + EmptyContent( + emptyTitle = stringResource(id = R.string.noToDosForNow), + emptyMessage = stringResource(id = R.string.noToDosForNowSubtext), + imageRes = R.drawable.ic_no_events, + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .testTag("todoListEmpty") + ) + } + + else -> { + ToDoItemsList( + itemsByDate = filteredItemsByDate, + onItemClicked = onOpenToDoItem, + onDateClick = onDateClick, + modifier = modifier + ) + } + } +} + +@Composable +private fun ToDoItemsList( + itemsByDate: Map>, + onItemClicked: (String) -> Unit, + onDateClick: (Date) -> Unit, + modifier: Modifier = Modifier +) { + val dateGroups = itemsByDate.entries.toList() + val listState = rememberLazyListState() + val configuration = LocalConfiguration.current + val itemPositions = remember(configuration) { mutableStateMapOf() } + val itemSizes = remember(configuration) { mutableStateMapOf() } + val density = LocalDensity.current + var listHeight by remember { mutableIntStateOf(0) } + + val stickyHeaderState = rememberStickyHeaderState( + dateGroups = dateGroups, + listState = listState, + itemPositions = itemPositions, + density = density + ) + + // Calculate content height from last item's position + size + val listContentHeight by remember(dateGroups, itemPositions) { + derivedStateOf { + if (dateGroups.isEmpty()) return@derivedStateOf 0 + val lastGroup = dateGroups.last() + val lastItem = lastGroup.value.last() + val lastItemPosition = itemPositions[lastItem.id] ?: return@derivedStateOf 0 + val lastItemSize = itemSizes[lastItem.id] ?: return@derivedStateOf 0 + (lastItemPosition + lastItemSize).toInt() + } + } + + // Calculate if there's enough space for pandas (at least 140dp) + val availableSpacePx = listHeight - listContentHeight + val minSpaceForPandasPx = with(density) { 140.dp.toPx() } + val showPandas = listHeight > 0 && listContentHeight > 0 && + availableSpacePx >= minSpaceForPandasPx && + itemsByDate.isNotEmpty() + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .testTag("todoList") + .onGloballyPositioned { coordinates -> + listHeight = coordinates.size.height + } + ) { + dateGroups.forEachIndexed { groupIndex, (date, items) -> + items.forEachIndexed { index, item -> + item(key = item.id) { + ToDoItem( + item = item, + showDateBadge = index == 0, + hideDate = index == 0 && stickyHeaderState.isVisible && stickyHeaderState.item?.id == item.id, + onCheckedChange = { item.onCheckboxToggle(!item.isChecked) }, + onClick = { + if (item.htmlUrl != null) { + onItemClicked(item.htmlUrl) + } else { + FirebaseCrashlytics.getInstance().recordException( + IllegalStateException("ToDoListScreen: Item clicked with null htmlUrl, id=${item.id}, title=${item.title}") + ) + } + }, + onDateClick = onDateClick, + modifier = Modifier + .animateItem() + .onGloballyPositioned { coordinates -> + itemPositions[item.id] = coordinates.positionInParent().y + itemSizes[item.id] = coordinates.size.height + } + ) + } + } + + // Add divider between date groups, or after last group if pandas are showing + if (groupIndex < dateGroups.size - 1 || showPandas) { + item(key = "divider_$date") { + val isLastDivider = groupIndex == dateGroups.size - 1 + CanvasDivider( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .conditional(!isLastDivider) { + padding(horizontal = 16.dp) + } + ) + } + } + } + } + + // Panda illustrations + if (showPandas) { + PandaIllustrations(contentHeightPx = listContentHeight.toFloat()) + } + + // Sticky header overlay + stickyHeaderState.item?.let { item -> + if (stickyHeaderState.isVisible) { + StickyDateBadge( + item = item, + yOffset = stickyHeaderState.yOffset, + onDateClick = onDateClick + ) + } + } + } +} + +@Composable +private fun StickyDateBadge( + item: ToDoItemUiState, + yOffset: Float, + onDateClick: (Date) -> Unit +) { + val dateBadgeData = rememberDateBadgeData(item.date) + + Box( + modifier = Modifier + .offset { IntOffset(0, yOffset.roundToInt()) } + .padding(start = 12.dp, top = 8.dp, bottom = 8.dp) + ) { + Box( + modifier = Modifier + .width(44.dp) + .padding(end = 12.dp) + .background(Color.Transparent) + .clip(CircleShape) + .clickable { onDateClick(item.date) }, + contentAlignment = Alignment.TopCenter + ) { + DateBadge(dateBadgeData) + } + } +} + +@Composable +private fun ToDoItem( + item: ToDoItemUiState, + showDateBadge: Boolean, + onCheckedChange: () -> Unit, + onClick: () -> Unit, + onDateClick: (Date) -> Unit, + modifier: Modifier = Modifier, + hideDate: Boolean = false +) { + val coroutineScope = rememberCoroutineScope() + val animatedOffsetX = remember { Animatable(0f) } + var itemWidth by remember { mutableFloatStateOf(0f) } + val density = LocalDensity.current + val view = LocalView.current + + // Track the isChecked state that SwipeBackground should display + // Only update when item has settled back (offsetX is 0) + var swipeBackgroundIsChecked by remember { mutableStateOf(item.isChecked) } + + // Update swipeBackgroundIsChecked only when offset is 0 and item.isChecked has changed + LaunchedEffect(animatedOffsetX.value, item.isChecked) { + if (animatedOffsetX.value == 0f && swipeBackgroundIsChecked != item.isChecked) { + swipeBackgroundIsChecked = item.isChecked + } + } + + val swipeThreshold = with(density) { SWIPE_THRESHOLD_DP.dp.toPx() } + + fun animateToCenter() { + coroutineScope.launch { + animatedOffsetX.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 300) + ) + } + } + + fun handleSwipeEnd() { + coroutineScope.launch { + val currentOffset = animatedOffsetX.value + val absOffset = abs(currentOffset) + if (absOffset >= swipeThreshold) { + val targetOffset = if (currentOffset > 0) itemWidth else -itemWidth + animatedOffsetX.animateTo( + targetValue = targetOffset, + animationSpec = tween(durationMillis = 200) + ) + + // Gesture end haptic feedback + view.performGestureHapticFeedback(isStart = false) + delay(300) + + // Trigger the action + item.onSwipeToDone() + + // Animate back to center - if item gets removed, it will disappear during/after animation + animateToCenter() + } else { + animateToCenter() + } + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .testTag("todoItem_${item.id}") + .onGloballyPositioned { coordinates -> + itemWidth = coordinates.size.width.toFloat() + } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { + // Gesture start haptic feedback when user begins dragging + view.performGestureHapticFeedback(isStart = true) + }, + onDragEnd = { handleSwipeEnd() }, + onDragCancel = { + animateToCenter() + }, + onHorizontalDrag = { _, dragAmount -> + coroutineScope.launch { + val newOffset = (animatedOffsetX.value + dragAmount).coerceIn(-itemWidth, itemWidth) + animatedOffsetX.snapTo(newOffset) + } + } + ) + } + ) { + SwipeBackground( + isChecked = swipeBackgroundIsChecked, + offsetX = animatedOffsetX.value + ) + + ToDoItemContent( + item = item, + showDateBadge = showDateBadge, + hideDate = hideDate, + onCheckedChange = onCheckedChange, + onClick = onClick, + onDateClick = onDateClick, + modifier = Modifier.offset { IntOffset(animatedOffsetX.value.roundToInt(), 0) } + ) + } +} + +@Composable +private fun BoxScope.SwipeBackground(isChecked: Boolean, offsetX: Float) { + val backgroundColor = if (isChecked) { + colorResource(R.color.backgroundDark) + } else { + colorResource(R.color.backgroundSuccess) + } + + val text = if (isChecked) { + stringResource(id = R.string.todoSwipeUndo) + } else { + stringResource(id = R.string.todoSwipeDone) + } + + val icon = if (isChecked) { + R.drawable.ic_reply + } else { + R.drawable.ic_checkmark_lined + } + + // Calculate alpha based on swipe progress with ease-in curve + val density = LocalDensity.current + val swipeThreshold = with(density) { SWIPE_THRESHOLD_DP.dp.toPx() } + val progress = (abs(offsetX) / swipeThreshold).coerceIn(0f, 1f) + // Apply ease-in cubic easing for gradual fade-in that accelerates near threshold + val alpha = progress * progress * progress + + Box( + modifier = Modifier + .matchParentSize() + .background(backgroundColor) + ) { + if (offsetX > 0) { + Row( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp) + .alpha(alpha), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = colorResource(R.color.textLightest), + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.textLightest) + ) + } + } + + if (offsetX < 0) { + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + .alpha(alpha), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.textLightest) + ) + Spacer(modifier = Modifier.width(12.dp)) + Icon( + painter = painterResource(id = icon), + contentDescription = null, + tint = colorResource(R.color.textLightest), + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +@Composable +private fun ToDoItemContent( + item: ToDoItemUiState, + showDateBadge: Boolean, + hideDate: Boolean, + onCheckedChange: () -> Unit, + onClick: () -> Unit, + onDateClick: (Date) -> Unit, + modifier: Modifier = Modifier +) { + val dateBadgeData = rememberDateBadgeData(item.date) + val view = LocalView.current + + Row( + modifier = modifier + .fillMaxWidth() + .background(colorResource(id = R.color.backgroundLightest)) + .clickable(enabled = item.isClickable, onClick = onClick) + .padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .width(44.dp) + .padding(end = 12.dp) + .clip(CircleShape) + .clickable { onDateClick(item.date) }, + contentAlignment = Alignment.TopCenter + ) { + if (showDateBadge && !hideDate) { + DateBadge(dateBadgeData) + } + } + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + val contextColor = Color(item.canvasContext.courseOrUserColor) + Row(verticalAlignment = Alignment.Top) { + Icon( + painter = painterResource(id = item.iconRes), + contentDescription = null, + tint = contextColor, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + CanvasDivider( + modifier = Modifier + .width(0.5.dp) + .height(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = item.contextLabel, + fontSize = 14.sp, + color = contextColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + Text( + text = item.title, + fontSize = 16.sp, + color = colorResource(id = R.color.textDarkest), + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + + item.tag?.let { + Text( + text = it, + fontSize = 14.sp, + color = colorResource(id = R.color.textDark), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + item.dateLabel?.let { + Text( + text = it, + fontSize = 14.sp, + color = colorResource(id = R.color.textDark), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Checkbox( + checked = item.isChecked, + onCheckedChange = { + // Determine if marking as done or undone based on the new checked state + view.performToggleHapticFeedback(it) + onCheckedChange() + }, + colors = CheckboxDefaults.colors( + checkedColor = Color(ThemePrefs.brandColor), + uncheckedColor = colorResource(id = R.color.textDark) + ), + modifier = Modifier.testTag("todoCheckbox_${item.id}") + ) + } + } +} + +@Composable +private fun rememberDateBadgeData(date: Date): DateBadgeData { + val calendar = remember(date) { + Calendar.getInstance().apply { time = date } + } + + val dayOfWeek = remember(date) { + SimpleDateFormat("EEE", Locale.getDefault()).format(date) + } + + val day = remember(date) { + calendar.get(Calendar.DAY_OF_MONTH) + } + + val month = remember(date) { + SimpleDateFormat("MMM", Locale.getDefault()).format(date) + } + + val isToday = remember(date) { + val today = Calendar.getInstance() + calendar.get(Calendar.YEAR) == today.get(Calendar.YEAR) && + calendar.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) + } + + val dateTextColor = if (isToday) { + Color(ThemePrefs.brandColor) + } else { + colorResource(id = R.color.textDark) + } + + return DateBadgeData(dayOfWeek, day, month, isToday, dateTextColor) +} + +@Composable +private fun DateBadge( + dateBadgeData: DateBadgeData +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = dateBadgeData.dayOfWeek, + fontSize = 12.sp, + color = dateBadgeData.dateTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Box( + contentAlignment = Alignment.Center, + modifier = if (dateBadgeData.isToday) { + Modifier + .size(32.dp) + .border(width = 1.dp, color = dateBadgeData.dateTextColor, shape = CircleShape) + } else { + Modifier + } + ) { + Text( + text = dateBadgeData.day.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = dateBadgeData.dateTextColor + ) + } + Text( + text = dateBadgeData.month, + fontSize = 10.sp, + color = dateBadgeData.dateTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun rememberStickyHeaderState( + dateGroups: List>>, + listState: LazyListState, + itemPositions: Map, + density: Density +): StickyHeaderState { + return remember(dateGroups, itemPositions) { + derivedStateOf { + calculateStickyHeaderState(dateGroups, listState, itemPositions, density) + } + }.value +} + +private fun calculateStickyHeaderState( + dateGroups: List>>, + listState: LazyListState, + itemPositions: Map, + density: Density +): StickyHeaderState { + val firstVisibleItemIndex = listState.firstVisibleItemIndex + val firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset + + // Find which date group's first item has been scrolled past + var currentGroupIndex = -1 + var itemCount = 0 + + for ((groupIndex, group) in dateGroups.withIndex()) { + val groupItemCount = group.value.size + if (firstVisibleItemIndex < itemCount + groupItemCount) { + currentGroupIndex = groupIndex + break + } + itemCount += groupItemCount + if (groupIndex < dateGroups.size - 1) 1 else 0 // +1 for divider + } + + if (currentGroupIndex == -1 || currentGroupIndex >= dateGroups.size) { + return StickyHeaderState(null, 0f, false) + } + + val currentGroup = dateGroups[currentGroupIndex] + val firstItemOfCurrentGroup = currentGroup.value.first() + + // Check if the first item has scrolled up even slightly + val shouldShowSticky = if (firstVisibleItemIndex > 0) { + true + } else { + firstVisibleItemScrollOffset > 0 + } + + // Calculate offset for animation when next group approaches + var yOffset = 0f + if (currentGroupIndex < dateGroups.size - 1) { + val nextGroup = dateGroups[currentGroupIndex + 1] + val nextGroupFirstItem = nextGroup.value.first() + val nextItemPosition = itemPositions[nextGroupFirstItem.id] ?: Float.MAX_VALUE + + // Calculate date badge height by converting sp and dp values to pixels + // Date badge components: + // - dayOfWeek text: 12.sp + // - day text (in 32.dp box): 12.sp (bold) + // - month text: 10.sp + // - All text heights together: 22.sp + // - item bottom padding: 8.dp + val textHeightPx = with(density) { 22.sp.toPx() } + val circleHeightPx = with(density) { 32.dp.toPx() } + val paddingPx = with(density) { 8.dp.toPx() } + val stickyHeaderHeightPx = textHeightPx + circleHeightPx + paddingPx + + if (nextItemPosition < stickyHeaderHeightPx && nextItemPosition > 0) { + yOffset = nextItemPosition - stickyHeaderHeightPx + } + } + + return StickyHeaderState( + item = if (shouldShowSticky) firstItemOfCurrentGroup else null, + yOffset = yOffset, + isVisible = shouldShowSticky + ) +} + +@Composable +private fun PandaIllustrations(contentHeightPx: Float) { + val density = LocalDensity.current + + Box(modifier = Modifier.fillMaxSize()) { + // Top-right panda - positioned at top-right, below content + Icon( + painter = painterResource(id = R.drawable.ic_panda_top), + contentDescription = null, + modifier = Modifier + .width(180.dp) + .height(137.dp) + .align(Alignment.TopEnd) + .offset(x = (-24).dp, y = with(density) { (contentHeightPx - 2.5.dp.toPx()).toDp() }), + tint = Color.Unspecified + ) + + // Bottom-left panda - positioned at bottom-left + Icon( + painter = painterResource(id = R.drawable.ic_panda_bottom), + contentDescription = null, + modifier = Modifier + .width(114.dp) + .height(137.dp) + .align(Alignment.BottomStart) + .offset(x = 24.dp, y = 30.5.dp), + tint = Color.Unspecified + ) + } +} + +@Preview(name = "Light Mode", showBackground = true) +@Preview(name = "Dark Mode", showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ToDoListScreenPreview() { + ContextKeeper.appContext = LocalContext.current + val calendar = Calendar.getInstance() + CanvasTheme { + ToDoListContent( + uiState = ToDoListUiState( + itemsByDate = mapOf( + Date(10) to listOf( + ToDoItemUiState( + id = "1", + title = "Short title", + date = calendar.apply { set(2024, 9, 22, 7, 59) }.time, + dateLabel = "7:59 AM", + contextLabel = "COURSE", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.ASSIGNMENT, + iconRes = R.drawable.ic_assignment, + isChecked = false + ), + ToDoItemUiState( + id = "2", + title = "Levitate an object without crushing it, bonus points if you don't scratch the paint", + date = calendar.apply { set(2024, 9, 22, 11, 59) }.time, + dateLabel = "11:59 AM", + contextLabel = "Introduction to Advanced Galactic Force Manipulation and Control Techniques for Beginners", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.QUIZ, + iconRes = R.drawable.ic_quiz, + isChecked = false + ), + ToDoItemUiState( + id = "3", + title = "Identify which emotions lead to Jedi calmness vs. a full Darth Vader office meltdown situation", + date = calendar.apply { set(2024, 9, 22, 14, 30) }.time, + dateLabel = "2:30 PM", + contextLabel = "FORC 101", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.ASSIGNMENT, + iconRes = R.drawable.ic_assignment, + isChecked = true + ), + ToDoItemUiState( + id = "4", + title = "Peer review discussion post", + date = calendar.apply { set(2024, 9, 22, 16, 0) }.time, + dateLabel = "4:00 PM", + tag = "Peer Reviews for Exploring Emotional Mastery", + contextLabel = "Advanced Force Psychology", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.SUB_ASSIGNMENT, + iconRes = R.drawable.ic_discussion, + isChecked = false + ) + ), + Date(1000) to listOf( + ToDoItemUiState( + id = "5", + title = "Essay - Why Force-choking co-workers is frowned upon in most galactic workplaces", + date = calendar.apply { set(2024, 9, 23, 19, 0) }.time, + dateLabel = "7:00 PM", + contextLabel = "Professional Jedi Ethics and Workplace Communication", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.DISCUSSION, + iconRes = R.drawable.ic_discussion, + isChecked = false + ), + ToDoItemUiState( + id = "6", + title = "Personal meditation practice", + date = calendar.apply { set(2024, 9, 23, 20, 0) }.time, + dateLabel = "8:00 PM", + contextLabel = "My Notes", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.PLANNER_NOTE, + iconRes = R.drawable.ic_todo, + isChecked = false + ), + ToDoItemUiState( + id = "7", + title = "Q", + date = calendar.apply { set(2024, 9, 23, 23, 59) }.time, + dateLabel = "11:59 PM", + contextLabel = "PHY", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.PLANNER_NOTE, + iconRes = R.drawable.ic_todo, + isChecked = false + ) + ), + Date(2000) to listOf( + ToDoItemUiState( + id = "9", + title = "Lightsaber maintenance workshop", + date = calendar.apply { set(2024, 9, 24, 10, 0) }.time, + dateLabel = "10:00 AM", + contextLabel = "Equipment & Safety", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.CALENDAR_EVENT, + iconRes = R.drawable.ic_calendar, + isChecked = false + ) + ) + ) + ), + onOpenToDoItem = {}, + onDateClick = {} + ) + } +} + +@Preview(name = "Empty Light Mode", showBackground = true) +@Composable +fun ToDoListScreenEmptyPreview() { + ContextKeeper.appContext = LocalContext.current + CanvasTheme { + ToDoListContent( + uiState = ToDoListUiState(), + onOpenToDoItem = {}, + onDateClick = {} + ) + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt new file mode 100644 index 0000000000..0666d1ca8a --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.pandautils.R +import java.util.Date + +data class ToDoListUiState( + val isLoading: Boolean = false, + val isError: Boolean = false, + val isRefreshing: Boolean = false, + val itemsByDate: Map> = emptyMap(), + val snackbarMessage: String? = null, + val onSnackbarDismissed: () -> Unit = {}, + val confirmationSnackbarData: ConfirmationSnackbarData? = null, + val onUndoMarkAsDoneUndoneAction: () -> Unit = {}, + val onMarkedAsDoneSnackbarDismissed: () -> Unit = {}, + val onRefresh: () -> Unit = {}, + val toDoCount: Int? = null, + val onToDoCountChanged: () -> Unit = {}, + val onFiltersChanged: (Boolean) -> Unit = {}, + val isFilterApplied: Boolean = false, + val removingItemIds: Set = emptySet() +) + +data class ConfirmationSnackbarData( + val itemId: String, + val title: String, + val markedAsDone: Boolean +) + +data class ToDoItemUiState( + val id: String, + val title: String, + val date: Date, + val dateLabel: String?, + val contextLabel: String, + val canvasContext: CanvasContext, + val itemType: ToDoItemType, + val isChecked: Boolean = false, + val iconRes: Int = R.drawable.ic_calendar, + val tag: String? = null, + val htmlUrl: String? = null, + val isClickable: Boolean = true, + val onSwipeToDone: () -> Unit = {}, + val onCheckboxToggle: (Boolean) -> Unit = {} +) + +enum class ToDoItemType { + ASSIGNMENT, + SUB_ASSIGNMENT, + QUIZ, + DISCUSSION, + CALENDAR_EVENT, + PLANNER_NOTE +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt new file mode 100644 index 0000000000..1130ea93d3 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt @@ -0,0 +1,522 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import android.content.Context +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.utils.Analytics +import com.instructure.canvasapi2.utils.AnalyticsEventConstants +import com.instructure.canvasapi2.utils.AnalyticsParamConstants +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.isInvited +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.pandautils.R +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.calendar.SharedCalendarAction +import com.instructure.pandautils.features.todolist.filter.DateRangeSelection +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity +import com.instructure.pandautils.utils.NetworkStateProvider +import com.instructure.pandautils.utils.filterByToDoFilters +import com.instructure.pandautils.utils.getContextNameForPlannerItem +import com.instructure.pandautils.utils.getDateTextForPlannerItem +import com.instructure.pandautils.utils.getIconForPlannerItem +import com.instructure.pandautils.utils.getTagForPlannerItem +import com.instructure.pandautils.utils.getUrl +import com.instructure.pandautils.utils.isComplete +import com.instructure.pandautils.utils.orDefault +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class ToDoListViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: ToDoListRepository, + private val networkStateProvider: NetworkStateProvider, + private val firebaseCrashlytics: FirebaseCrashlytics, + private val toDoFilterDao: ToDoFilterDao, + private val apiPrefs: ApiPrefs, + private val analytics: Analytics, + private val toDoListViewModelBehavior: ToDoListViewModelBehavior, + private val calendarSharedEvents: CalendarSharedEvents, +) : ViewModel() { + + private val _uiState = MutableStateFlow( + ToDoListUiState( + onSnackbarDismissed = { clearSnackbarMessage() }, + onUndoMarkAsDoneUndoneAction = { handleUndoMarkAsDoneUndone() }, + onMarkedAsDoneSnackbarDismissed = { clearMarkedAsDoneItem() }, + onRefresh = { handleRefresh() }, + onFiltersChanged = { dateFiltersChanged -> onFiltersChanged(dateFiltersChanged) } + )) + + private fun onFiltersChanged(dateFiltersChanged: Boolean) { + // Update widget + toDoListViewModelBehavior.updateWidget(false) + if (dateFiltersChanged) { + loadData(forceRefresh = false) + } else { + applyFiltersLocally() + } + } + + private fun applyFiltersLocally() { + viewModelScope.launch { + try { + val todoFilters = toDoFilterDao.findByUser( + apiPrefs.fullDomain, + apiPrefs.user?.id.orDefault() + ) ?: ToDoFilterEntity(userDomain = apiPrefs.fullDomain, userId = apiPrefs.user?.id.orDefault()) + + val allPlannerItems = plannerItemsMap.values.toList() + val filteredCourses = courseMap.values.toList() + + processAndUpdateItems(allPlannerItems, filteredCourses, todoFilters) + } catch (e: Exception) { + e.printStackTrace() + firebaseCrashlytics.recordException(e) + } + } + } + + val uiState = _uiState.asStateFlow() + + private val plannerItemsMap = mutableMapOf() + private var courseMap = mapOf() + + // Track items removed via checkbox for debounced clearing + private val checkboxRemovedItems = mutableSetOf() + private var checkboxDebounceJob: Job? = null + + init { + loadData() + observeCalendarSharedEvents() + } + + private fun observeCalendarSharedEvents() { + viewModelScope.launch { + calendarSharedEvents.events.collect { action -> + when (action) { + is SharedCalendarAction.RefreshToDoList -> { + loadData(forceRefresh = true) + } + else -> {} // Ignore other calendar actions + } + } + } + } + + private fun loadData(forceRefresh: Boolean = false) { + viewModelScope.launch { + try { + _uiState.update { it.copy(isLoading = !forceRefresh, isRefreshing = forceRefresh, isError = false) } + + val todoFilters = toDoFilterDao.findByUser( + apiPrefs.fullDomain, + apiPrefs.user?.id.orDefault() + ) ?: ToDoFilterEntity(userDomain = apiPrefs.fullDomain, userId = apiPrefs.user?.id.orDefault()) + + val startDate = todoFilters.pastDateRange.calculatePastDateRange().toApiString() + val endDate = todoFilters.futureDateRange.calculateFutureDateRange().toApiString() + + val courses = repository.getCourses(forceRefresh).dataOrThrow + val plannerItems = repository.getPlannerItems(startDate, endDate, forceRefresh).dataOrThrow + .filter { it.plannableType != PlannableType.ANNOUNCEMENT && it.plannableType != PlannableType.ASSESSMENT_REQUEST } + + // Store planner items for later reference + plannerItemsMap.clear() + plannerItems.forEach { plannerItemsMap[it.plannable.id.toString()] = it } + + // Filter courses - exclude access restricted, invited + val filteredCourses = courses.filter { + !it.accessRestrictedByDate && !it.isInvited() + } + courseMap = filteredCourses.associateBy { it.id } + + processAndUpdateItems(plannerItems, filteredCourses, todoFilters) + + // Track analytics event for filter loading + trackFilterLoadingEvent(todoFilters) + } catch (e: Exception) { + e.printStackTrace() + firebaseCrashlytics.recordException(e) + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + isError = true + ) + } + } + } + } + + private fun mapToUiState(plannerItem: PlannerItem, courseMap: Map): ToDoItemUiState { + val itemType = when (plannerItem.plannableType) { + PlannableType.ASSIGNMENT -> ToDoItemType.ASSIGNMENT + PlannableType.SUB_ASSIGNMENT -> ToDoItemType.SUB_ASSIGNMENT + PlannableType.QUIZ -> ToDoItemType.QUIZ + PlannableType.DISCUSSION_TOPIC -> ToDoItemType.DISCUSSION + PlannableType.CALENDAR_EVENT -> ToDoItemType.CALENDAR_EVENT + PlannableType.PLANNER_NOTE -> ToDoItemType.PLANNER_NOTE + else -> ToDoItemType.CALENDAR_EVENT + } + + val itemId = plannerItem.plannable.id.toString() + + // Account-level calendar events should not be clickable + val isAccountLevelEvent = plannerItem.contextType?.equals("Account", ignoreCase = true) == true + val isClickable = !(isAccountLevelEvent && itemType == ToDoItemType.CALENDAR_EVENT) + + return ToDoItemUiState( + id = itemId, + title = plannerItem.plannable.title, + date = plannerItem.plannableDate, + dateLabel = plannerItem.getDateTextForPlannerItem(context), + contextLabel = plannerItem.getContextNameForPlannerItem(context, courseMap.values), + canvasContext = plannerItem.canvasContext, + itemType = itemType, + isChecked = plannerItem.isComplete(), + iconRes = plannerItem.getIconForPlannerItem(), + tag = plannerItem.getTagForPlannerItem(context), + htmlUrl = plannerItem.getUrl(apiPrefs), + isClickable = isClickable, + onSwipeToDone = { handleSwipeToDone(itemId) }, + onCheckboxToggle = { isChecked -> handleCheckboxToggle(itemId, isChecked) } + ) + } + + private fun handleSwipeToDone(itemId: String) { + viewModelScope.launch { + if (!networkStateProvider.isOnline()) { + _uiState.update { + it.copy(snackbarMessage = context.getString(R.string.todoActionOffline)) + } + return@launch + } + + val plannerItem = plannerItemsMap[itemId] ?: return@launch + val currentIsChecked = plannerItem.isComplete() + val newIsChecked = !currentIsChecked + + // Check if we should show completed items + val todoFilters = toDoFilterDao.findByUser( + apiPrefs.fullDomain, + apiPrefs.user?.id.orDefault() + ) ?: ToDoFilterEntity(userDomain = apiPrefs.fullDomain, userId = apiPrefs.user?.id.orDefault()) + + val shouldRemoveFromList = newIsChecked && !todoFilters.showCompleted + + // Immediately add to removing set for animation if item should be hidden + if (shouldRemoveFromList) { + _uiState.update { + it.copy(removingItemIds = it.removingItemIds + itemId) + } + } + + val success = updateItemCompleteState(itemId, newIsChecked) + + // Show marked-as-done snackbar only when marking as done (not when undoing) + if (success) { + _uiState.update { + it.copy( + confirmationSnackbarData = ConfirmationSnackbarData( + itemId = itemId, + title = plannerItem.plannable.title, + markedAsDone = newIsChecked + ) + ) + } + } else { + // Remove from removing set if update failed + if (shouldRemoveFromList) { + _uiState.update { + it.copy(removingItemIds = it.removingItemIds - itemId) + } + } + } + } + } + + private fun handleUndoMarkAsDoneUndone() { + viewModelScope.launch { + val markedAsDoneItem = _uiState.value.confirmationSnackbarData ?: return@launch + val itemId = markedAsDoneItem.itemId + + // If this item was in checkbox-removed items, remove it and reset timer + if (itemId in checkboxRemovedItems) { + checkboxRemovedItems.remove(itemId) + startCheckboxDebounceTimer() + } + + // Clear the snackbar immediately and restore item to list + _uiState.update { + it.copy( + confirmationSnackbarData = null, + removingItemIds = it.removingItemIds - itemId + ) + } + + updateItemCompleteState(itemId, !markedAsDoneItem.markedAsDone) + } + } + + private fun handleCheckboxToggle(itemId: String, isChecked: Boolean) { + viewModelScope.launch { + if (!networkStateProvider.isOnline()) { + _uiState.update { + it.copy(snackbarMessage = context.getString(R.string.todoActionOffline)) + } + return@launch + } + + val plannerItem = plannerItemsMap[itemId] ?: return@launch + + // Check if we should show completed items + val todoFilters = toDoFilterDao.findByUser( + apiPrefs.fullDomain, + apiPrefs.user?.id.orDefault() + ) ?: ToDoFilterEntity(userDomain = apiPrefs.fullDomain, userId = apiPrefs.user?.id.orDefault()) + + val shouldRemoveFromList = isChecked && !todoFilters.showCompleted + + // Handle checkbox removal animation + if (shouldRemoveFromList) { + // Add to pending removal set (will be removed after debounce) + checkboxRemovedItems.add(itemId) + } else if (!isChecked && itemId in checkboxRemovedItems) { + // Unchecking - remove from pending removal + checkboxRemovedItems.remove(itemId) + // If item was already in removingItemIds, restore it + _uiState.update { + it.copy(removingItemIds = it.removingItemIds - itemId) + } + } + + // Reset debounce timer + startCheckboxDebounceTimer() + + val success = updateItemCompleteState(itemId, isChecked) + + // Show marked-as-done snackbar only when checking the box + if (success) { + _uiState.update { + it.copy( + confirmationSnackbarData = ConfirmationSnackbarData( + itemId = itemId, + title = plannerItem.plannable.title, + markedAsDone = isChecked + ) + ) + } + } else { + // Remove from pending removal if update failed + if (shouldRemoveFromList) { + checkboxRemovedItems.remove(itemId) + } + } + } + } + + private fun startCheckboxDebounceTimer() { + // Cancel existing timer + checkboxDebounceJob?.cancel() + + // Only start timer if there are items pending removal + if (checkboxRemovedItems.isEmpty()) { + return + } + + // Start new 1-second timer + checkboxDebounceJob = viewModelScope.launch { + delay(1000) + + // Add checkbox-removed items to removingItemIds for animation + _uiState.update { state -> + state.copy( + removingItemIds = state.removingItemIds + checkboxRemovedItems + ) + } + checkboxRemovedItems.clear() + } + } + + private suspend fun updateItemCompleteState(itemId: String, newIsChecked: Boolean): Boolean { + val plannerItem = plannerItemsMap[itemId] ?: return false + val currentIsChecked = plannerItem.isComplete() + + // Optimistically update UI + updateItemCheckedState(itemId, newIsChecked) + + return try { + // Update or create planner override + val plannerOverrideResult = if (plannerItem.plannerOverride?.id != null) { + repository.updatePlannerOverride( + plannerOverrideId = plannerItem.plannerOverride?.id.orDefault(), + markedComplete = newIsChecked + ).dataOrThrow + } else { + repository.createPlannerOverride( + plannableId = plannerItem.plannable.id, + plannableType = plannerItem.plannableType, + markedComplete = newIsChecked + ).dataOrThrow + } + + // Update the stored planner item with new override state + val updatedPlannerItem = plannerItem.copy(plannerOverride = plannerOverrideResult) + plannerItemsMap[itemId] = updatedPlannerItem + + // Invalidate planner cache + repository.invalidateCachedResponses() + toDoListViewModelBehavior.updateWidget(true) + + // Track analytics event + if (newIsChecked) { + analytics.logEvent(AnalyticsEventConstants.TODO_ITEM_MARKED_DONE) + } else { + analytics.logEvent(AnalyticsEventConstants.TODO_ITEM_MARKED_UNDONE) + } + + true + } catch (e: Exception) { + e.printStackTrace() + firebaseCrashlytics.recordException(e) + // Revert the optimistic update + updateItemCheckedState(itemId, currentIsChecked) + // Show error snackbar + _uiState.update { + it.copy(snackbarMessage = context.getString(R.string.errorUpdatingToDo)) + } + false + } + } + + private fun updateItemCheckedState(itemId: String, isChecked: Boolean) { + _uiState.update { state -> + val updatedItemsByDate = state.itemsByDate.mapValues { (_, items) -> + items.map { item -> + if (item.id == itemId) { + item.copy(isChecked = isChecked) + } else { + item + } + } + } + val toDoCount = calculateToDoCount(updatedItemsByDate) + state.copy(itemsByDate = updatedItemsByDate, toDoCount = toDoCount) + } + } + + private fun calculateToDoCount(itemsByDate: Map>): Int { + return itemsByDate.values.flatten().count { !it.isChecked } + } + + private fun handleRefresh() { + loadData(forceRefresh = true) + } + + private fun clearSnackbarMessage() { + _uiState.update { it.copy(snackbarMessage = null) } + } + + private fun clearMarkedAsDoneItem() { + _uiState.update { + it.copy(confirmationSnackbarData = null) + } + } + + private fun processAndUpdateItems( + plannerItems: List, + filteredCourses: List, + todoFilters: ToDoFilterEntity + ) { + // Cancel checkbox debounce and clear tracked items when data reloads + checkboxDebounceJob?.cancel() + checkboxRemovedItems.clear() + + val filteredItems = plannerItems + .filterByToDoFilters(todoFilters, filteredCourses) + .sortedBy { it.comparisonDate } + + val itemsByDate = filteredItems + .groupBy { DateHelper.getCleanDate(it.comparisonDate.time) } + .mapValues { (_, items) -> + items.map { plannerItem -> + mapToUiState(plannerItem, courseMap) + } + } + + val toDoCount = calculateToDoCount(itemsByDate) + val isFilterApplied = isFilterApplied(todoFilters) + + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + isError = false, + itemsByDate = itemsByDate, + toDoCount = toDoCount, + isFilterApplied = isFilterApplied, + removingItemIds = emptySet(), // Clear removing items when data is reprocessed + confirmationSnackbarData = null + ) + } + } + + private fun trackFilterLoadingEvent(filters: ToDoFilterEntity) { + val isDefaultFilter = !filters.personalTodos && + !filters.calendarEvents && + !filters.showCompleted && + !filters.favoriteCourses && + filters.pastDateRange == DateRangeSelection.FOUR_WEEKS && + filters.futureDateRange == DateRangeSelection.THIS_WEEK + + if (isDefaultFilter) { + analytics.logEvent(AnalyticsEventConstants.TODO_LIST_LOADED_DEFAULT_FILTER) + } else { + val bundle = Bundle().apply { + putString(AnalyticsParamConstants.FILTER_PERSONAL_TODOS, filters.personalTodos.toString()) + putString(AnalyticsParamConstants.FILTER_CALENDAR_EVENTS, filters.calendarEvents.toString()) + putString(AnalyticsParamConstants.FILTER_SHOW_COMPLETED, filters.showCompleted.toString()) + putString(AnalyticsParamConstants.FILTER_FAVOURITE_COURSES, filters.favoriteCourses.toString()) + putString(AnalyticsParamConstants.FILTER_SELECTED_DATE_RANGE_PAST, filters.pastDateRange.name.lowercase()) + putString(AnalyticsParamConstants.FILTER_SELECTED_DATE_RANGE_FUTURE, filters.futureDateRange.name.lowercase()) + } + analytics.logEvent(AnalyticsEventConstants.TODO_LIST_LOADED_CUSTOM_FILTER, bundle) + } + } + + private fun isFilterApplied(filters: ToDoFilterEntity): Boolean { + return filters.personalTodos || filters.calendarEvents || filters.showCompleted || filters.favoriteCourses + || filters.pastDateRange != DateRangeSelection.FOUR_WEEKS || filters.futureDateRange != DateRangeSelection.THIS_WEEK + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModelBehavior.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModelBehavior.kt new file mode 100644 index 0000000000..233e4baed8 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModelBehavior.kt @@ -0,0 +1,23 @@ +/* + * 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.pandautils.features.todolist + + +interface ToDoListViewModelBehavior { + fun updateWidget(forceRefresh: Boolean = true) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterScreen.kt new file mode 100644 index 0000000000..2e2ce612f8 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterScreen.kt @@ -0,0 +1,371 @@ +/* + * Copyright (C) 2025 - 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.todolist.filter + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.compose.composables.CanvasDivider +import com.instructure.pandautils.compose.composables.CanvasThemedAppBar +import com.instructure.pandautils.compose.composables.CheckboxText +import com.instructure.pandautils.utils.ThemePrefs + +@Composable +fun ToDoFilterScreen( + onFiltersChanged: (areDateFiltersChanged: Boolean) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val viewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState.shouldCloseAndApplyFilters) { + if (uiState.shouldCloseAndApplyFilters) { + onFiltersChanged(uiState.areDateFiltersChanged) + uiState.onFiltersApplied() + } + } + + Scaffold( + backgroundColor = colorResource(R.color.backgroundLightest), + topBar = { + CanvasThemedAppBar( + title = stringResource(id = R.string.todoFilterPreferences), + navIconRes = R.drawable.ic_close, + navIconContentDescription = stringResource(id = R.string.close), + navigationActionClick = onDismiss, + actions = { + TextButton( + onClick = { + uiState.onDone() + } + ) { + Text( + text = stringResource(id = R.string.done), + color = Color(ThemePrefs.primaryTextColor), + fontSize = 14.sp + ) + } + } + ) + }, + modifier = modifier + ) { padding -> + ToDoFilterContent( + uiState = uiState, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) + } +} + +@Composable +fun ToDoFilterContent( + uiState: ToDoFilterUiState, + modifier: Modifier = Modifier +) { + LazyColumn(modifier = modifier.testTag("ToDoFilterContent")) { + item { + SectionHeader(title = stringResource(id = R.string.todoFilterVisibleItems)) + } + + uiState.checkboxItems.forEachIndexed { index, item -> + item { + CheckboxItem( + title = stringResource(id = item.titleRes), + checked = item.checked, + onCheckedChange = item.onToggle, + showDivider = index == uiState.checkboxItems.lastIndex + ) + } + } + + item { + SectionHeader(title = stringResource(id = R.string.todoFilterShowTasksFrom)) + } + + item { + DateRangeOptions( + options = uiState.pastDateOptions, + selectedOption = uiState.selectedPastOption, + onOptionSelected = uiState.onPastDaysChanged, + showDivider = true + ) + } + + item { + SectionHeader(title = stringResource(id = R.string.todoFilterShowTasksUntil)) + } + + item { + DateRangeOptions( + options = uiState.futureDateOptions, + selectedOption = uiState.selectedFutureOption, + onOptionSelected = uiState.onFutureDaysChanged, + showDivider = false + ) + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Column { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(id = R.color.textDark), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + CanvasDivider( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp) + ) + } +} + +@Composable +private fun CheckboxItem( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + showDivider: Boolean = false +) { + Column { + CheckboxText( + text = title, + selected = checked, + color = Color(ThemePrefs.brandColor), + onCheckedChanged = onCheckedChange, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) + .padding(horizontal = 4.dp) + ) + + if (showDivider) { + CanvasDivider(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun DateRangeOptions( + options: List, + selectedOption: DateRangeSelection, + onOptionSelected: (DateRangeSelection) -> Unit, + showDivider: Boolean +) { + Column { + options.forEachIndexed { index, option -> + DateRangeOptionItem( + option = option, + isSelected = selectedOption == option.selection, + onSelected = { onOptionSelected(option.selection) } + ) + } + + if (showDivider) { + CanvasDivider(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun DateRangeOptionItem( + option: DateRangeOption, + isSelected: Boolean, + onSelected: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) + .clickable(onClick = onSelected) + .padding(start = 16.dp, end = 4.dp) + .semantics(mergeDescendants = true) { + contentDescription = "${option.labelText}, ${option.dateText}" + stateDescription = if (isSelected) "selected" else "not selected" + }, + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = onSelected, + colors = RadioButtonDefaults.colors( + selectedColor = Color(ThemePrefs.brandColor), + unselectedColor = Color(ThemePrefs.brandColor) + ), + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = option.labelText, + fontSize = 16.sp, + color = colorResource(id = R.color.textDarkest) + ) + + Text( + text = option.dateText, + fontSize = 14.sp, + color = colorResource(id = R.color.textDark) + ) + } + + Spacer(modifier = Modifier.width(24.dp)) + } +} + +@Preview(name = "Light Mode", showBackground = true) +@Composable +fun ToDoFilterScreenPreview() { + ContextKeeper.appContext = LocalContext.current + CanvasTheme { + ToDoFilterContent( + uiState = ToDoFilterUiState( + checkboxItems = listOf( + FilterCheckboxItem( + titleRes = R.string.todoFilterShowPersonalToDos, + checked = false, + onToggle = {} + ), + FilterCheckboxItem( + titleRes = R.string.todoFilterShowCalendarEvents, + checked = true, + onToggle = {} + ), + FilterCheckboxItem( + titleRes = R.string.todoFilterShowCompleted, + checked = false, + onToggle = {} + ), + FilterCheckboxItem( + titleRes = R.string.todoFilterFavoriteCoursesOnly, + checked = true, + onToggle = {} + ) + ), + pastDateOptions = listOf( + DateRangeOption( + selection = DateRangeSelection.FOUR_WEEKS, + labelText = "4 Weeks Ago", + dateText = "From 7 Oct" + ), + DateRangeOption( + selection = DateRangeSelection.THREE_WEEKS, + labelText = "3 Weeks Ago", + dateText = "From 14 Oct" + ), + DateRangeOption( + selection = DateRangeSelection.TWO_WEEKS, + labelText = "2 Weeks Ago", + dateText = "From 21 Oct" + ), + DateRangeOption( + selection = DateRangeSelection.ONE_WEEK, + labelText = "Last Week", + dateText = "From 28 Oct" + ), + DateRangeOption( + selection = DateRangeSelection.THIS_WEEK, + labelText = "This Week", + dateText = "From 4 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.TODAY, + labelText = "Today", + dateText = "From 4 Nov" + ) + ), + selectedPastOption = DateRangeSelection.ONE_WEEK, + futureDateOptions = listOf( + DateRangeOption( + selection = DateRangeSelection.TODAY, + labelText = "Today", + dateText = "Until 4 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.THIS_WEEK, + labelText = "This Week", + dateText = "Until 10 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.ONE_WEEK, + labelText = "Next Week", + dateText = "Until 17 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.TWO_WEEKS, + labelText = "In 2 Weeks", + dateText = "Until 24 Nov" + ), + DateRangeOption( + selection = DateRangeSelection.THREE_WEEKS, + labelText = "In 3 Weeks", + dateText = "Until 1 Dec" + ), + DateRangeOption( + selection = DateRangeSelection.FOUR_WEEKS, + labelText = "In 4 Weeks", + dateText = "Until 8 Dec" + ) + ), + selectedFutureOption = DateRangeSelection.ONE_WEEK + ) + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterUiState.kt new file mode 100644 index 0000000000..d39c198330 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterUiState.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2025 - 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.todolist.filter + +import com.instructure.pandautils.R +import java.util.Calendar +import java.util.Date + +data class ToDoFilterUiState( + val checkboxItems: List = emptyList(), + val pastDateOptions: List = emptyList(), + val selectedPastOption: DateRangeSelection = DateRangeSelection.ONE_WEEK, + val futureDateOptions: List = emptyList(), + val selectedFutureOption: DateRangeSelection = DateRangeSelection.ONE_WEEK, + val shouldCloseAndApplyFilters: Boolean = false, + val areDateFiltersChanged: Boolean = false, + val onPastDaysChanged: (DateRangeSelection) -> Unit = {}, + val onFutureDaysChanged: (DateRangeSelection) -> Unit = {}, + val onDone: () -> Unit = {}, + val onFiltersApplied: () -> Unit = {} +) + +data class FilterCheckboxItem( + val titleRes: Int, + val checked: Boolean, + val onToggle: (Boolean) -> Unit +) + +data class DateRangeOption( + val selection: DateRangeSelection, + val labelText: String, + val dateText: String +) + +enum class DateRangeSelection(val pastLabelResId: Int, val futureLabelResId: Int) { + TODAY(R.string.todoFilterToday, R.string.todoFilterToday), + THIS_WEEK(R.string.todoFilterThisWeek, R.string.todoFilterThisWeek), + ONE_WEEK(R.string.todoFilterLastWeek, R.string.todoFilterNextWeek), + TWO_WEEKS(R.string.todoFilterTwoWeeks, R.string.todoFilterInTwoWeeks), + THREE_WEEKS(R.string.todoFilterThreeWeeks, R.string.todoFilterInThreeWeeks), + FOUR_WEEKS(R.string.todoFilterFourWeeks, R.string.todoFilterInFourWeeks); + + fun calculatePastDateRange(): Date { + val calendar = Calendar.getInstance().apply { time = Date() } + + val weeksToAdd = when (this) { + TODAY -> return calendar.apply { setStartOfDay() }.time + THIS_WEEK -> 0 + ONE_WEEK -> -1 + TWO_WEEKS -> -2 + THREE_WEEKS -> -3 + FOUR_WEEKS -> -4 + } + + return calendar.apply { + add(Calendar.WEEK_OF_YEAR, weeksToAdd) + set(Calendar.DAY_OF_WEEK, calendar.firstDayOfWeek) + setStartOfDay() + }.time + } + + fun calculateFutureDateRange(): Date { + val calendar = Calendar.getInstance().apply { time = Date() } + + val weeksToAdd = when (this) { + TODAY -> return calendar.apply { setEndOfDay() }.time + THIS_WEEK -> 0 + ONE_WEEK -> 1 + TWO_WEEKS -> 2 + THREE_WEEKS -> 3 + FOUR_WEEKS -> 4 + } + + return calendar.apply { + add(Calendar.WEEK_OF_YEAR, weeksToAdd) + set(Calendar.DAY_OF_WEEK, calendar.firstDayOfWeek) + add(Calendar.DAY_OF_YEAR, 6) + setEndOfDay() + }.time + } +} + +private fun Calendar.setStartOfDay() { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) +} + +private fun Calendar.setEndOfDay() { + set(Calendar.HOUR_OF_DAY, 23) + set(Calendar.MINUTE, 59) + set(Calendar.SECOND, 59) + set(Calendar.MILLISECOND, 999) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterViewModel.kt new file mode 100644 index 0000000000..fd2d672278 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterViewModel.kt @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2025 - 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.todolist.filter + +import android.content.Context +import android.text.format.DateFormat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.R +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity +import com.instructure.pandautils.utils.orDefault +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +private const val FILTER_PERSONAL_TODOS = "personal_todos" +private const val FILTER_CALENDAR_EVENTS = "calendar_events" +private const val FILTER_SHOW_COMPLETED = "show_completed" +private const val FILTER_FAVORITE_COURSES = "favorite_courses" + +@HiltViewModel +class ToDoFilterViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val apiPrefs: ApiPrefs, + private val toDoFilterDao: ToDoFilterDao +) : ViewModel() { + + private val checkboxStates = mutableMapOf( + FILTER_PERSONAL_TODOS to false, + FILTER_CALENDAR_EVENTS to false, + FILTER_SHOW_COMPLETED to false, + FILTER_FAVORITE_COURSES to false + ) + + private var selectedPastOption = DateRangeSelection.FOUR_WEEKS + private var selectedFutureOption = DateRangeSelection.THIS_WEEK + + private val _uiState = MutableStateFlow(createInitialUiState()) + val uiState = _uiState.asStateFlow() + + init { + loadFiltersFromDatabase() + } + + private fun loadFiltersFromDatabase() { + viewModelScope.launch { + val savedFilters = toDoFilterDao.findByUser( + apiPrefs.fullDomain, + apiPrefs.user?.id.orDefault() + ) + + if (savedFilters != null) { + checkboxStates[FILTER_PERSONAL_TODOS] = savedFilters.personalTodos + checkboxStates[FILTER_CALENDAR_EVENTS] = savedFilters.calendarEvents + checkboxStates[FILTER_SHOW_COMPLETED] = savedFilters.showCompleted + checkboxStates[FILTER_FAVORITE_COURSES] = savedFilters.favoriteCourses + selectedPastOption = savedFilters.pastDateRange + selectedFutureOption = savedFilters.futureDateRange + } + + _uiState.update { createInitialUiState() } + } + } + + private fun createInitialUiState(): ToDoFilterUiState { + return ToDoFilterUiState( + checkboxItems = createCheckboxItems(), + pastDateOptions = createPastDateOptions(), + selectedPastOption = selectedPastOption, + futureDateOptions = createFutureDateOptions(), + selectedFutureOption = selectedFutureOption, + onPastDaysChanged = { handlePastDaysChanged(it) }, + onFutureDaysChanged = { handleFutureDaysChanged(it) }, + onDone = { handleDone() }, + onFiltersApplied = { handleFiltersApplied() } + ) + } + + private fun handleFiltersApplied() { + _uiState.update { + it.copy(shouldCloseAndApplyFilters = false, areDateFiltersChanged = false) + } + } + + private fun createCheckboxItems(): List { + return listOf( + FilterCheckboxItem( + titleRes = R.string.todoFilterShowPersonalToDos, + checked = checkboxStates[FILTER_PERSONAL_TODOS] ?: false, + onToggle = { handleCheckboxToggle(FILTER_PERSONAL_TODOS, it) } + ), + FilterCheckboxItem( + titleRes = R.string.todoFilterShowCalendarEvents, + checked = checkboxStates[FILTER_CALENDAR_EVENTS] ?: true, + onToggle = { handleCheckboxToggle(FILTER_CALENDAR_EVENTS, it) } + ), + FilterCheckboxItem( + titleRes = R.string.todoFilterShowCompleted, + checked = checkboxStates[FILTER_SHOW_COMPLETED] ?: false, + onToggle = { handleCheckboxToggle(FILTER_SHOW_COMPLETED, it) } + ), + FilterCheckboxItem( + titleRes = R.string.todoFilterFavoriteCoursesOnly, + checked = checkboxStates[FILTER_FAVORITE_COURSES] ?: false, + onToggle = { handleCheckboxToggle(FILTER_FAVORITE_COURSES, it) } + ) + ) + } + + private fun handleCheckboxToggle(id: String, checked: Boolean) { + checkboxStates[id] = checked + _uiState.update { + it.copy(checkboxItems = createCheckboxItems()) + } + } + + private fun handlePastDaysChanged(option: DateRangeSelection) { + selectedPastOption = option + _uiState.update { + it.copy(selectedPastOption = option) + } + } + + private fun handleFutureDaysChanged(option: DateRangeSelection) { + selectedFutureOption = option + _uiState.update { + it.copy(selectedFutureOption = option) + } + } + + private fun handleDone() { + viewModelScope.launch { + val savedFilters = toDoFilterDao.findByUser( + apiPrefs.fullDomain, + apiPrefs.user?.id.orDefault() + ) + + val areDateFiltersChanged = savedFilters?.let { + it.pastDateRange != selectedPastOption || it.futureDateRange != selectedFutureOption + } ?: true + + val filterEntity = ToDoFilterEntity( + id = savedFilters?.id ?: 0, + userDomain = apiPrefs.fullDomain, + userId = apiPrefs.user?.id.orDefault(), + personalTodos = checkboxStates[FILTER_PERSONAL_TODOS] ?: false, + calendarEvents = checkboxStates[FILTER_CALENDAR_EVENTS] ?: false, + showCompleted = checkboxStates[FILTER_SHOW_COMPLETED] ?: false, + favoriteCourses = checkboxStates[FILTER_FAVORITE_COURSES] ?: false, + pastDateRange = selectedPastOption, + futureDateRange = selectedFutureOption + ) + toDoFilterDao.insertOrUpdate(filterEntity) + + _uiState.update { + it.copy( + shouldCloseAndApplyFilters = true, + areDateFiltersChanged = areDateFiltersChanged + ) + } + } + } + + private fun formatDateText(date: Date, isPast: Boolean): String { + val pattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), "dMMM") + val dateFormat = SimpleDateFormat(pattern, Locale.getDefault()) + val formattedDate = dateFormat.format(date) + return if (isPast) { + context.getString(R.string.todoFilterFromDate, formattedDate) + } else { + context.getString(R.string.todoFilterUntilDate, formattedDate) + } + } + + private fun createPastDateOptions(): List { + return DateRangeSelection.entries.reversed().map { selection -> + val date = selection.calculatePastDateRange() + DateRangeOption( + selection = selection, + labelText = context.getString(selection.pastLabelResId), + dateText = formatDateText(date, isPast = true) + ) + } + } + + private fun createFutureDateOptions(): List { + return DateRangeSelection.entries.map { selection -> + val date = selection.calculateFutureDateRange() + DateRangeOption( + selection = selection, + labelText = context.getString(selection.futureLabelResId), + dateText = formatDateText(date, isPast = false) + ) + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt index 0f75d10ff9..160be1ba0a 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt @@ -14,6 +14,7 @@ import com.instructure.pandautils.room.appdatabase.daos.ModuleBulkProgressDao import com.instructure.pandautils.room.appdatabase.daos.PendingSubmissionCommentDao import com.instructure.pandautils.room.appdatabase.daos.ReminderDao import com.instructure.pandautils.room.appdatabase.daos.SubmissionCommentDao +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao import com.instructure.pandautils.room.appdatabase.entities.AttachmentEntity import com.instructure.pandautils.room.appdatabase.entities.AuthorEntity import com.instructure.pandautils.room.appdatabase.entities.DashboardFileUploadEntity @@ -25,6 +26,7 @@ import com.instructure.pandautils.room.appdatabase.entities.ModuleBulkProgressEn import com.instructure.pandautils.room.appdatabase.entities.PendingSubmissionCommentEntity import com.instructure.pandautils.room.appdatabase.entities.ReminderEntity import com.instructure.pandautils.room.appdatabase.entities.SubmissionCommentEntity +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity import com.instructure.pandautils.room.assignment.list.converter.AssignmentFilterConverter import com.instructure.pandautils.room.assignment.list.daos.AssignmentListSelectedFiltersEntityDao import com.instructure.pandautils.room.assignment.list.entities.AssignmentListSelectedFiltersEntity @@ -43,8 +45,9 @@ import com.instructure.pandautils.room.common.Converters ReminderEntity::class, ModuleBulkProgressEntity::class, AssignmentListSelectedFiltersEntity::class, - FileDownloadProgressEntity::class - ], version = 13 + FileDownloadProgressEntity::class, + ToDoFilterEntity::class + ], version = 14 ) @TypeConverters(Converters::class, AssignmentFilterConverter::class) abstract class AppDatabase : RoomDatabase() { @@ -72,4 +75,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun assignmentListSelectedFiltersEntityDao(): AssignmentListSelectedFiltersEntityDao abstract fun fileDownloadProgressDao(): FileDownloadProgressDao + + abstract fun toDoFilterDao(): ToDoFilterDao } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt index bcdd9e678b..409456969f 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt @@ -74,4 +74,8 @@ val appDatabaseMigrations = arrayOf( createMigration(12, 13) { database -> database.execSQL("ALTER TABLE ReminderEntity ADD COLUMN tag TEXT") }, + + createMigration(13, 14) { database -> + database.execSQL("CREATE TABLE IF NOT EXISTS todo_filter (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, userDomain TEXT NOT NULL, userId INTEGER NOT NULL, personalTodos INTEGER NOT NULL DEFAULT 0, calendarEvents INTEGER NOT NULL DEFAULT 0, showCompleted INTEGER NOT NULL DEFAULT 0, favoriteCourses INTEGER NOT NULL DEFAULT 0, pastDateRange TEXT NOT NULL DEFAULT 'ONE_WEEK', futureDateRange TEXT NOT NULL DEFAULT 'ONE_WEEK')") + }, ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ToDoFilterDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ToDoFilterDao.kt new file mode 100644 index 0000000000..c5a5a57f8c --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/ToDoFilterDao.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2025 - 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.room.appdatabase.daos + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity + +@Dao +interface ToDoFilterDao { + + @Upsert + suspend fun insertOrUpdate(filter: ToDoFilterEntity) + + @Query("SELECT * FROM todo_filter WHERE userDomain = :userDomain AND userId = :userId") + suspend fun findByUser(userDomain: String, userId: Long): ToDoFilterEntity? + + @Query("DELETE FROM todo_filter WHERE userDomain = :userDomain AND userId = :userId") + suspend fun deleteByUser(userDomain: String, userId: Long) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ToDoFilterEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ToDoFilterEntity.kt new file mode 100644 index 0000000000..510640ee00 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/ToDoFilterEntity.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025 - 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.room.appdatabase.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.instructure.pandautils.features.todolist.filter.DateRangeSelection + +@Entity(tableName = "todo_filter") +data class ToDoFilterEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val userDomain: String, + val userId: Long, + val personalTodos: Boolean = false, + val calendarEvents: Boolean = false, + val showCompleted: Boolean = false, + val favoriteCourses: Boolean = false, + val pastDateRange: DateRangeSelection = DateRangeSelection.FOUR_WEEKS, + val futureDateRange: DateRangeSelection = DateRangeSelection.THIS_WEEK +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseProvider.kt index cadabe9354..6f141f57eb 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseProvider.kt @@ -46,6 +46,7 @@ class OfflineDatabaseProvider( return dbMap.getOrPut(userId) { Room.databaseBuilder(context, OfflineDatabase::class.java, "$OFFLINE_DB_PREFIX$userId") .addMigrations(*offlineDatabaseMigrations) + .fallbackToDestructiveMigration() .build() } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/StudentDb.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/StudentDb.kt index 240333f333..a4ad2ae585 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/StudentDb.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/StudentDb.kt @@ -29,7 +29,7 @@ import com.instructure.pandautils.room.studentdb.entities.daos.CreatePendingSubm import com.instructure.pandautils.room.studentdb.entities.daos.CreateSubmissionCommentFileDao @Database( - version = 6, + version = 7, entities = [ CreateSubmissionEntity::class, CreatePendingSubmissionCommentEntity::class, diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/StudentDbMigration.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/StudentDbMigration.kt index 5244536491..9a470a043e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/StudentDbMigration.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/StudentDbMigration.kt @@ -17,6 +17,13 @@ import com.instructure.pandautils.room.common.createMigration val studentDbMigrations = arrayOf( + createMigration(6, 7) { database -> + database.execSQL("ALTER TABLE `CreateSubmissionEntity` ADD COLUMN `submission_state` TEXT NOT NULL DEFAULT 'QUEUED'") + database.execSQL("ALTER TABLE `CreateSubmissionEntity` ADD COLUMN `state_updated_at` INTEGER") + database.execSQL("ALTER TABLE `CreateSubmissionEntity` ADD COLUMN `retry_count` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE `CreateSubmissionEntity` ADD COLUMN `last_error_message` TEXT") + database.execSQL("ALTER TABLE `CreateSubmissionEntity` ADD COLUMN `canvas_submission_id` INTEGER") + }, createMigration(5, 6) { database -> // Add attempt column to CreateSubmissionEntity table database.execSQL("ALTER TABLE `CreateSubmissionEntity` ADD COLUMN `attempt` INTEGER NOT NULL DEFAULT 1") diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/CreateSubmissionEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/CreateSubmissionEntity.kt index 0a519c0453..d29c254f88 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/CreateSubmissionEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/CreateSubmissionEntity.kt @@ -15,6 +15,7 @@ */ package com.instructure.pandautils.room.studentdb.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import com.instructure.canvasapi2.models.CanvasContext @@ -40,5 +41,15 @@ data class CreateSubmissionEntity( val isDraft: Boolean = false, val attempt: Long = 1L, val mediaType: String? = null, - val mediaSource: String? = null + val mediaSource: String? = null, + @ColumnInfo(name = "submission_state", defaultValue = "QUEUED") + val submissionState: SubmissionState = SubmissionState.QUEUED, + @ColumnInfo(name = "state_updated_at") + val stateUpdatedAt: Date? = null, + @ColumnInfo(name = "retry_count", defaultValue = "0") + val retryCount: Int = 0, + @ColumnInfo(name = "last_error_message") + val lastErrorMessage: String? = null, + @ColumnInfo(name = "canvas_submission_id") + val canvasSubmissionId: Long? = null ) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/SubmissionState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/SubmissionState.kt new file mode 100644 index 0000000000..ebb54f7e69 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/SubmissionState.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2025 - 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.room.studentdb.entities + +/** + * State transitions: + * QUEUED -> UPLOADING_FILES -> SUBMITTING -> VERIFYING -> COMPLETED + * | | | + * v v v + * RETRYING -----> RETRYING ----> RETRYING + * | | | + * v v v + * FAILED FAILED FAILED + */ +enum class SubmissionState { + QUEUED, + UPLOADING_FILES, + SUBMITTING, + VERIFYING, + COMPLETED, + FAILED, + RETRYING; + + val isActive: Boolean + get() = this in listOf(QUEUED, UPLOADING_FILES, SUBMITTING, VERIFYING, RETRYING) + + val isTerminal: Boolean + get() = this in listOf(COMPLETED, FAILED) + + val isError: Boolean + get() = this in listOf(FAILED, RETRYING) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/daos/CreateSubmissionDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/daos/CreateSubmissionDao.kt index ab0282b9af..879f87e135 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/daos/CreateSubmissionDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/studentdb/entities/daos/CreateSubmissionDao.kt @@ -20,7 +20,9 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import com.instructure.pandautils.room.studentdb.entities.CreateSubmissionEntity +import com.instructure.pandautils.room.studentdb.entities.SubmissionState import kotlinx.coroutines.flow.Flow +import java.util.Date @Dao interface CreateSubmissionDao { @@ -105,4 +107,16 @@ interface CreateSubmissionDao { @Query("DELETE FROM CreateSubmissionEntity WHERE assignmentId = :assignmentId AND userId = :userId AND submissionType = :submissionType AND isDraft = 1") suspend fun deleteDraftByAssignmentIdAndType(assignmentId: Long, userId: Long, submissionType: String) + + @Query("UPDATE CreateSubmissionEntity SET submission_state = :state, state_updated_at = :timestamp WHERE id = :id") + suspend fun updateSubmissionState(id: Long, state: SubmissionState, timestamp: Date = Date()) + + @Query("UPDATE CreateSubmissionEntity SET retry_count = retry_count + 1, last_error_message = :error WHERE id = :id") + suspend fun incrementRetryCount(id: Long, error: String?) + + @Query("UPDATE CreateSubmissionEntity SET canvas_submission_id = :submissionId WHERE id = :id") + suspend fun setCanvasSubmissionId(id: Long, submissionId: Long) + + @Query("SELECT * FROM CreateSubmissionEntity WHERE submission_state IN (:states)") + suspend fun findSubmissionsByState(states: List): List } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AssignmentExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AssignmentExtensions.kt index 62a68193ee..74bb2f39b0 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AssignmentExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AssignmentExtensions.kt @@ -257,8 +257,8 @@ fun Assignment.getSubAssignmentSubmissionStateLabel( submission?.late.orDefault(), submission?.missing.orDefault(), !submission?.grade.isNullOrEmpty(), - submitted = false, // TODO: Sub-assignments do not have a submittedAt field - notSubmitted = false // TODO: Sub-assignments do not have a submittedAt field + submission?.submittedAt != null, + submission?.submittedAt == null ) val Assignment.orderedCheckpoints: List diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AssignmentUtils2.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AssignmentUtils2.kt index 1d62448623..7b3d3072bc 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AssignmentUtils2.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AssignmentUtils2.kt @@ -18,6 +18,9 @@ package com.instructure.pandautils.utils import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.AssignmentDueDate +import com.instructure.canvasapi2.models.AssignmentOverride +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Submission import java.util.* @@ -127,3 +130,66 @@ object AssignmentUtils2 { } } } + +fun Assignment.isAllowedToSubmitWithOverrides(course: Course?): Boolean { + val submissionTypes = getSubmissionTypes() + + if (!expectsSubmissions() || + submissionTypes.contains(Assignment.SubmissionType.ONLINE_QUIZ) || + submissionTypes.contains(Assignment.SubmissionType.ATTENDANCE)) { + return false + } + + if (!lockedForUser) { + return true + } + + if (!hasOverrides || course?.enrollments.isNullOrEmpty()) { + return false + } + + val userSectionIds = course?.enrollments + ?.filter { it.isStudent } + ?.map { it.courseSectionId } + ?.filter { it != 0L } + ?: emptyList() + + if (userSectionIds.isEmpty()) { + return false + } + + val currentTime = Date() + + val sectionOverrides = overrides?.filter { override -> + userSectionIds.contains(override.courseSectionId) + } ?: emptyList() + + if (sectionOverrides.isNotEmpty()) { + return sectionOverrides.any { override -> + isAccessibleWithinDateRange(currentTime, override.unlockAt, override.lockAt) + } + } + + val sectionDueDates = allDates.filter { dueDate -> + val matchingOverride = overrides?.find { it.id == dueDate.id } + matchingOverride != null && userSectionIds.contains(matchingOverride.courseSectionId) + } + + return sectionDueDates.any { dueDate -> + isAccessibleWithinDateRange(currentTime, dueDate.unlockDate, dueDate.lockDate) + } +} + +private fun isAccessibleWithinDateRange(currentTime: Date, unlockDate: Date?, lockDate: Date?): Boolean { + val afterUnlock = unlockDate == null || currentTime.after(unlockDate) || currentTime == unlockDate + val beforeLock = lockDate == null || currentTime.before(lockDate) + return afterUnlock && beforeLock +} + +private fun Assignment.expectsSubmissions(): Boolean { + val submissionTypes = getSubmissionTypes() + return submissionTypes.isNotEmpty() && + !submissionTypes.contains(Assignment.SubmissionType.NONE) && + !submissionTypes.contains(Assignment.SubmissionType.NOT_GRADED) && + !submissionTypes.contains(Assignment.SubmissionType.ON_PAPER) +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FileDownloader.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FileDownloader.kt index 3fb3294fbf..9883b47b11 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FileDownloader.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FileDownloader.kt @@ -15,13 +15,18 @@ */ package com.instructure.pandautils.utils +import android.Manifest import android.app.DownloadManager import android.content.Context +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Environment import android.webkit.CookieManager +import android.widget.Toast +import androidx.core.content.ContextCompat import com.instructure.canvasapi2.models.Attachment +import com.instructure.pandautils.R class FileDownloader( private val context: Context, @@ -37,6 +42,14 @@ class FileDownloader( filename: String?, contentType: String? ) { + if (downloadURL == null) { + Toast.makeText( + context, + context.getString(R.string.errorOccurred), + Toast.LENGTH_SHORT + ).show() + return + } downloadFileToDevice(Uri.parse(downloadURL), filename, contentType) } @@ -67,5 +80,24 @@ class FileDownloader( val downloadId = downloadManager.enqueue(request) downloadNotificationHelper.monitorDownload(downloadId, filename) + + showNotificationPermissionToastIfNeeded() + } + + private fun showNotificationPermissionToastIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val hasPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + + if (!hasPermission) { + Toast.makeText( + context, + context.getString(R.string.fileDownloadNotificationPermissionDenied), + Toast.LENGTH_LONG + ).show() + } + } } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt index 8019185672..db545ff387 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt @@ -18,15 +18,41 @@ package com.instructure.pandautils.utils import android.content.Context import androidx.annotation.DrawableRes +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.PlannableType import com.instructure.canvasapi2.models.PlannerItem import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.toDate import com.instructure.pandautils.R +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity fun PlannerItem.todoHtmlUrl(apiPrefs: ApiPrefs): String { return "${apiPrefs.fullDomain}/todos/${this.plannable.id}" } +fun PlannerItem.getUrl(apiPrefs: ApiPrefs): String { + val url = when (plannableType) { + PlannableType.CALENDAR_EVENT -> { + "/${canvasContext.type.apiString}/${canvasContext.id}/calendar_events/${plannable.id}" + } + + PlannableType.PLANNER_NOTE -> { + "/todos/${plannable.id}" + } + + else -> { + htmlUrl.orEmpty() + } + } + + return if (url.startsWith("/")) { + apiPrefs.fullDomain + url + } else { + url + } +} + @DrawableRes fun PlannerItem.getIconForPlannerItem(): Int { return when (this.plannableType) { @@ -39,6 +65,60 @@ fun PlannerItem.getIconForPlannerItem(): Int { } } +fun PlannerItem.getDateTextForPlannerItem(context: Context): String? { + return when (plannableType) { + PlannableType.PLANNER_NOTE -> { + plannable.todoDate.toDate()?.let { + DateHelper.getFormattedTime(context, it) + } + } + + PlannableType.CALENDAR_EVENT -> { + val startDate = plannable.startAt + val endDate = plannable.endAt + if (startDate != null && endDate != null) { + val startText = DateHelper.getFormattedTime(context, startDate).orEmpty() + val endText = DateHelper.getFormattedTime(context, endDate).orEmpty() + + when { + plannable.allDay == true -> context.getString(R.string.widgetAllDay) + startDate == endDate -> startText + else -> context.getString(R.string.widgetFromTo, startText, endText) + } + } else null + } + + else -> { + plannable.dueAt?.let { + return DateHelper.getFormattedTime(context, it).orEmpty() + } + } + } +} + +fun PlannerItem.getContextNameForPlannerItem(context: Context, courses: Collection): String { + val course = courses.find { it.id == canvasContext.id } + val hasNickname = course?.originalName != null + val courseTitle = if (hasNickname) course.name else course?.courseCode + return when (plannableType) { + PlannableType.PLANNER_NOTE -> { + if (contextName.isNullOrEmpty()) { + context.getString(R.string.userCalendarToDo) + } else { + context.getString(R.string.courseToDo, courseTitle ?: contextName) + } + } + + else -> { + if (canvasContext is Course) { + courseTitle.orEmpty() + } else { + contextName.orEmpty() + } + } + } +} + fun PlannerItem.getTagForPlannerItem(context: Context): String? { return if (plannable.subAssignmentTag == Const.REPLY_TO_TOPIC) { context.getString(R.string.reply_to_topic) @@ -51,3 +131,49 @@ fun PlannerItem.getTagForPlannerItem(context: Context): String? { null } } + +fun PlannerItem.isComplete(): Boolean { + return plannerOverride?.markedComplete ?: if (plannableType == PlannableType.ASSIGNMENT + || plannableType == PlannableType.DISCUSSION_TOPIC + || plannableType == PlannableType.SUB_ASSIGNMENT + || plannableType == PlannableType.QUIZ + ) { + submissionState?.submitted == true + } else { + false + } +} + +fun List.filterByToDoFilters( + filters: ToDoFilterEntity, + courses: Collection +): List { + return this.filter { item -> + if (!filters.personalTodos && item.plannableType == PlannableType.PLANNER_NOTE) { + return@filter false + } + + if (!filters.calendarEvents && item.plannableType == PlannableType.CALENDAR_EVENT) { + return@filter false + } + + if (!filters.showCompleted && item.isComplete()) { + return@filter false + } + + if (filters.favoriteCourses) { + val courseIdToCheck = item.courseId ?: if (item.plannableType == PlannableType.PLANNER_NOTE) { + item.plannable.courseId + } else { + null + } + + val course = courses.find { it.id == courseIdToCheck } + if (course != null && !course.isFavorite) { + return@filter false + } + } + + true + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt index c764887a1e..a651bad475 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt @@ -35,10 +35,12 @@ import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Build import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet import android.util.TypedValue +import android.view.HapticFeedbackConstants import android.view.Menu import android.view.MenuItem import android.view.TouchDelegate @@ -990,3 +992,29 @@ fun View.showSnackbar( snackbar.show() snackbar.view.requestAccessibilityFocus(1000) } + +/** + * Performs haptic feedback with appropriate constants based on API level. + * Uses TOGGLE_ON/TOGGLE_OFF on API 34+ for marking done/undone, falls back to CONTEXT_CLICK on older versions. + */ +fun View.performToggleHapticFeedback(toggleOn: Boolean) { + val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (toggleOn) HapticFeedbackConstants.TOGGLE_ON else HapticFeedbackConstants.TOGGLE_OFF + } else { + HapticFeedbackConstants.CONTEXT_CLICK + } + performHapticFeedback(hapticConstant) +} + +/** + * Performs haptic feedback for gesture start/end with appropriate constants based on API level. + * Uses GESTURE_START/GESTURE_END on API 34+, falls back to CONTEXT_CLICK on older versions. + */ +fun View.performGestureHapticFeedback(isStart: Boolean) { + val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (isStart) HapticFeedbackConstants.GESTURE_START else HapticFeedbackConstants.GESTURE_END + } else { + HapticFeedbackConstants.CONTEXT_CLICK + } + performHapticFeedback(hapticConstant) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/res/drawable/ic_panda_bottom.xml b/libs/pandautils/src/main/res/drawable/ic_panda_bottom.xml new file mode 100644 index 0000000000..e7a042bc9d --- /dev/null +++ b/libs/pandautils/src/main/res/drawable/ic_panda_bottom.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + diff --git a/libs/pandautils/src/main/res/drawable/ic_panda_top.xml b/libs/pandautils/src/main/res/drawable/ic_panda_top.xml new file mode 100644 index 0000000000..f3605adfc9 --- /dev/null +++ b/libs/pandautils/src/main/res/drawable/ic_panda_top.xml @@ -0,0 +1,45 @@ + + + + + + + + + + diff --git a/libs/pandautils/src/main/res/layout/dialog_loading_view.xml b/libs/pandautils/src/main/res/layout/dialog_loading_view.xml index 067320791d..85e8dbe3ec 100644 --- a/libs/pandautils/src/main/res/layout/dialog_loading_view.xml +++ b/libs/pandautils/src/main/res/layout/dialog_loading_view.xml @@ -40,9 +40,9 @@ style="@style/TextFont.Medium"/> diff --git a/libs/pandautils/src/main/res/values/strings.xml b/libs/pandautils/src/main/res/values/strings.xml index 08cd2fdc95..3ac38d2694 100644 --- a/libs/pandautils/src/main/res/values/strings.xml +++ b/libs/pandautils/src/main/res/values/strings.xml @@ -487,6 +487,7 @@ %s Mark as done %s Mark as not done %s + Expand or collapse section Failed to load inbox signature Inbox settings saved! Failed to save inbox settings diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryTest.kt new file mode 100644 index 0000000000..c2ea6bdb39 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/accountnotification/AccountNotificationRepositoryTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2025 - 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.accountnotification + +import com.instructure.canvasapi2.apis.AccountNotificationAPI +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.DataResult +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.Before +import org.junit.Test + +class AccountNotificationRepositoryTest { + + private val accountNotificationApi: AccountNotificationAPI.AccountNotificationInterface = mockk(relaxed = true) + private lateinit var repository: AccountNotificationRepository + + @Before + fun setup() { + repository = AccountNotificationRepositoryImpl(accountNotificationApi) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `getAccountNotifications returns success with notification list`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z"), + AccountNotification(id = 2L, subject = "Announcement 2", message = "Message 2", icon = "warning", startAt = "1970-01-01T00:00:02Z") + ) + val expected = DataResult.Success(notifications) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + val result = repository.getAccountNotifications(forceRefresh = false) + + assertEquals(expected, result) + coVerify { + accountNotificationApi.getAccountNotifications( + params = match { !it.isForceReadFromNetwork && it.usePerPageQueryParam }, + includePast = false, + showIsClosed = false + ) + } + } + + @Test + fun `getAccountNotifications with forceRefresh passes correct params`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + val expected = DataResult.Success(notifications) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + repository.getAccountNotifications(forceRefresh = true) + + coVerify { + accountNotificationApi.getAccountNotifications( + params = match { it.isForceReadFromNetwork && it.usePerPageQueryParam }, + includePast = false, + showIsClosed = false + ) + } + } + + @Test + fun `getAccountNotifications returns failure`() = runTest { + val expected = DataResult.Fail() + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + val result = repository.getAccountNotifications(forceRefresh = false) + + assertEquals(expected, result) + } + + @Test + fun `getAccountNotifications with forceRefresh false uses cache`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Cached Announcement", message = "Cached Message", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + val expected = DataResult.Success(notifications) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + val result = repository.getAccountNotifications(forceRefresh = false) + + assertEquals(expected, result) + coVerify(exactly = 1) { + accountNotificationApi.getAccountNotifications( + params = match { !it.isForceReadFromNetwork }, + includePast = false, + showIsClosed = false + ) + } + } + + @Test + fun `getAccountNotifications handles empty list`() = runTest { + val expected = DataResult.Success(emptyList()) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returns expected + + val result = repository.getAccountNotifications(forceRefresh = false) + + assertEquals(expected, result) + assertEquals(0, (result as DataResult.Success).data.size) + } + + @Test + fun `getAccountNotifications handles multiple consecutive calls`() = runTest { + val notifications1 = listOf( + AccountNotification(id = 1L, subject = "First Call", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + val notifications2 = listOf( + AccountNotification(id = 2L, subject = "Second Call", message = "Message 2", icon = "warning", startAt = "1970-01-01T00:00:02Z") + ) + coEvery { + accountNotificationApi.getAccountNotifications(any(), any(), any()) + } returnsMany listOf(DataResult.Success(notifications1), DataResult.Success(notifications2)) + + val result1 = repository.getAccountNotifications(forceRefresh = false) + val result2 = repository.getAccountNotifications(forceRefresh = true) + + assertEquals("First Call", (result1 as DataResult.Success).data[0].subject) + assertEquals("Second Call", (result2 as DataResult.Success).data[0].subject) + } + + @Test + fun `deleteAccountNotification returns success`() = runTest { + val notification = AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + val expected = DataResult.Success(notification) + coEvery { + accountNotificationApi.deleteAccountNotification(any(), any()) + } returns expected + + val result = repository.deleteAccountNotification(accountNotificationId = 1L) + + assertEquals(expected, result) + coVerify { + accountNotificationApi.deleteAccountNotification(1L, any()) + } + } + + @Test + fun `deleteAccountNotification returns failure`() = runTest { + val expected = DataResult.Fail() + coEvery { + accountNotificationApi.deleteAccountNotification(any(), any()) + } returns expected + + val result = repository.deleteAccountNotification(accountNotificationId = 1L) + + assertEquals(expected, result) + } + + @Test + fun `deleteAccountNotification passes correct id`() = runTest { + val notification = AccountNotification(id = 123L, subject = "Announcement", message = "Message", icon = "info", startAt = "1970-01-01T00:00:01Z") + val expected = DataResult.Success(notification) + coEvery { + accountNotificationApi.deleteAccountNotification(any(), any()) + } returns expected + + val result = repository.deleteAccountNotification(accountNotificationId = 123L) + + assertEquals(expected, result) + coVerify { + accountNotificationApi.deleteAccountNotification(123L, any()) + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/course/CourseRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/course/CourseRepositoryTest.kt new file mode 100644 index 0000000000..b00e677a41 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/course/CourseRepositoryTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2025 - 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.course + +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.utils.DataResult +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.Before +import org.junit.Test + +class CourseRepositoryTest { + + private val courseApi: CourseAPI.CoursesInterface = mockk(relaxed = true) + private lateinit var repository: CourseRepository + + @Before + fun setup() { + repository = CourseRepositoryImpl(courseApi) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `getCourse returns success with course`() = runTest { + val course = Course(id = 100L, name = "Test Course") + val expected = DataResult.Success(course) + coEvery { + courseApi.getCourse(any(), any()) + } returns expected + + val result = repository.getCourse( + courseId = 100L, + forceRefresh = false + ) + + assertEquals(expected, result) + coVerify { + courseApi.getCourse( + 100L, + match { !it.isForceReadFromNetwork } + ) + } + } + + @Test + fun `getCourse with forceRefresh passes correct params`() = runTest { + val course = Course(id = 200L, name = "Another Course") + val expected = DataResult.Success(course) + coEvery { + courseApi.getCourse(any(), any()) + } returns expected + + val result = repository.getCourse( + courseId = 200L, + forceRefresh = true + ) + + assertEquals(expected, result) + coVerify { + courseApi.getCourse( + 200L, + match { it.isForceReadFromNetwork } + ) + } + } + + @Test + fun `getCourse returns failure`() = runTest { + val expected = DataResult.Fail() + coEvery { + courseApi.getCourse(any(), any()) + } returns expected + + val result = repository.getCourse( + courseId = 100L, + forceRefresh = false + ) + + assertEquals(expected, result) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryTest.kt new file mode 100644 index 0000000000..40f4e445b9 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/enrollment/EnrollmentRepositoryTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2025 - 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.enrollment + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.utils.DataResult +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.Before +import org.junit.Test + +class EnrollmentRepositoryTest { + + private val enrollmentApi: EnrollmentAPI.EnrollmentInterface = mockk(relaxed = true) + private lateinit var repository: EnrollmentRepository + + @Before + fun setup() { + repository = EnrollmentRepositoryImpl(enrollmentApi) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `getSelfEnrollments returns success with enrollments`() = runTest { + val enrollments = listOf( + Enrollment(id = 1L, courseId = 100L, userId = 10L), + Enrollment(id = 2L, courseId = 200L, userId = 10L) + ) + val expected = DataResult.Success(enrollments) + coEvery { + enrollmentApi.getFirstPageSelfEnrollments(any(), any(), any()) + } returns expected + + val result = repository.getSelfEnrollments( + types = null, + states = listOf("invited"), + forceRefresh = false + ) + + assertEquals(expected, result) + coVerify { + enrollmentApi.getFirstPageSelfEnrollments( + null, + listOf("invited"), + match { !it.isForceReadFromNetwork } + ) + } + } + + @Test + fun `getSelfEnrollments with forceRefresh passes correct params`() = runTest { + val expected = DataResult.Success(emptyList()) + coEvery { + enrollmentApi.getFirstPageSelfEnrollments(any(), any(), any()) + } returns expected + + repository.getSelfEnrollments( + types = listOf("StudentEnrollment"), + states = listOf("active"), + forceRefresh = true + ) + + coVerify { + enrollmentApi.getFirstPageSelfEnrollments( + listOf("StudentEnrollment"), + listOf("active"), + match { it.isForceReadFromNetwork } + ) + } + } + + @Test + fun `getSelfEnrollments returns failure`() = runTest { + val expected = DataResult.Fail() + coEvery { + enrollmentApi.getFirstPageSelfEnrollments(any(), any(), any()) + } returns expected + + val result = repository.getSelfEnrollments( + types = null, + states = null, + forceRefresh = false + ) + + assertEquals(expected, result) + } + + @Test + fun `handleInvitation accept calls API with accept action`() = runTest { + val expected = DataResult.Success(Unit) + coEvery { + enrollmentApi.handleInvite(any(), any(), any(), any()) + } returns expected + + val result = repository.handleInvitation( + courseId = 100L, + enrollmentId = 1L, + accept = true + ) + + assertEquals(expected, result) + coVerify { + enrollmentApi.handleInvite( + 100L, + 1L, + "accept", + any() + ) + } + } + + @Test + fun `handleInvitation reject calls API with reject action`() = runTest { + val expected = DataResult.Success(Unit) + coEvery { + enrollmentApi.handleInvite(any(), any(), any(), any()) + } returns expected + + val result = repository.handleInvitation( + courseId = 200L, + enrollmentId = 2L, + accept = false + ) + + assertEquals(expected, result) + coVerify { + enrollmentApi.handleInvite( + 200L, + 2L, + "reject", + any() + ) + } + } + + @Test + fun `handleInvitation returns failure`() = runTest { + val expected = DataResult.Fail() + coEvery { + enrollmentApi.handleInvite(any(), any(), any(), any()) + } returns expected + + val result = repository.handleInvitation( + courseId = 100L, + enrollmentId = 1L, + accept = true + ) + + assertEquals(expected, result) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/user/UserRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/user/UserRepositoryTest.kt new file mode 100644 index 0000000000..50f042df65 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/user/UserRepositoryTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2025 - 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.user + +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.canvasapi2.models.Account +import com.instructure.canvasapi2.utils.DataResult +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.Before +import org.junit.Test + +class UserRepositoryTest { + + private val userApi: UserAPI.UsersInterface = mockk(relaxed = true) + private lateinit var repository: UserRepository + + @Before + fun setup() { + repository = UserRepositoryImpl(userApi) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `getAccount returns success with account data`() = runTest { + val account = Account(id = 1L, name = "Test Institution") + val expected = DataResult.Success(account) + coEvery { + userApi.getAccount(any()) + } returns expected + + val result = repository.getAccount(forceRefresh = false) + + assertEquals(expected, result) + coVerify { + userApi.getAccount(match { !it.isForceReadFromNetwork }) + } + } + + @Test + fun `getAccount with forceRefresh passes correct params`() = runTest { + val account = Account(id = 1L, name = "Test Institution") + val expected = DataResult.Success(account) + coEvery { + userApi.getAccount(any()) + } returns expected + + repository.getAccount(forceRefresh = true) + + coVerify { + userApi.getAccount(match { it.isForceReadFromNetwork }) + } + } + + @Test + fun `getAccount returns failure`() = runTest { + val expected = DataResult.Fail() + coEvery { + userApi.getAccount(any()) + } returns expected + + val result = repository.getAccount(forceRefresh = false) + + assertEquals(expected, result) + } + + @Test + fun `getAccount with forceRefresh false uses cache`() = runTest { + val account = Account(id = 1L, name = "Cached Institution") + val expected = DataResult.Success(account) + coEvery { + userApi.getAccount(any()) + } returns expected + + val result = repository.getAccount(forceRefresh = false) + + assertEquals(expected, result) + coVerify(exactly = 1) { + userApi.getAccount(match { !it.isForceReadFromNetwork }) + } + } + + @Test + fun `getAccount handles account with empty name`() = runTest { + val account = Account(id = 1L, name = "") + val expected = DataResult.Success(account) + coEvery { + userApi.getAccount(any()) + } returns expected + + val result = repository.getAccount(forceRefresh = false) + + assertEquals(expected, result) + assertEquals("", (result as DataResult.Success).data.name) + } + + @Test + fun `getAccount handles multiple consecutive calls`() = runTest { + val account1 = Account(id = 1L, name = "First Call") + val account2 = Account(id = 1L, name = "Second Call") + coEvery { + userApi.getAccount(any()) + } returnsMany listOf(DataResult.Success(account1), DataResult.Success(account2)) + + val result1 = repository.getAccount(forceRefresh = false) + val result2 = repository.getAccount(forceRefresh = true) + + assertEquals("First Call", (result1 as DataResult.Success).data.name) + assertEquals("Second Call", (result2 as DataResult.Success).data.name) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCaseTest.kt new file mode 100644 index 0000000000..9ca2e259b8 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/accountnotification/LoadInstitutionalAnnouncementsUseCaseTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2025 - 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.accountnotification + +import com.instructure.canvasapi2.models.Account +import com.instructure.canvasapi2.models.AccountNotification +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepository +import com.instructure.pandautils.data.repository.user.UserRepository +import com.instructure.pandautils.utils.ThemePrefs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Date + +class LoadInstitutionalAnnouncementsUseCaseTest { + + private val accountNotificationRepository: AccountNotificationRepository = mockk(relaxed = true) + private val userRepository: UserRepository = mockk(relaxed = true) + private lateinit var useCase: LoadInstitutionalAnnouncementsUseCase + + @Before + fun setup() { + mockkObject(ThemePrefs) + every { ThemePrefs.brandColor } returns 0xFF0000FF.toInt() + every { ThemePrefs.mobileLogoUrl } returns "https://example.com/logo.png" + + useCase = LoadInstitutionalAnnouncementsUseCase( + accountNotificationRepository, + userRepository, + ThemePrefs + ) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `execute returns sorted announcements limited to 5`() = runTest { + val date1 = Date(1000L) + val date2 = Date(2000L) + val date3 = Date(3000L) + val date4 = Date(4000L) + val date5 = Date(5000L) + val date6 = Date(6000L) + + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z"), + AccountNotification(id = 2L, subject = "Announcement 2", message = "Message 2", icon = "warning", startAt = "1970-01-01T00:00:03Z"), + AccountNotification(id = 3L, subject = "Announcement 3", message = "Message 3", icon = "calendar", startAt = "1970-01-01T00:00:06Z"), + AccountNotification(id = 4L, subject = "Announcement 4", message = "Message 4", icon = "question", startAt = "1970-01-01T00:00:02Z"), + AccountNotification(id = 5L, subject = "Announcement 5", message = "Message 5", icon = "error", startAt = "1970-01-01T00:00:05Z"), + AccountNotification(id = 6L, subject = "Announcement 6", message = "Message 6", icon = "info", startAt = "1970-01-01T00:00:04Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(5, result.size) + assertEquals(3L, result[0].id) + assertEquals("Announcement 3", result[0].subject) + assertEquals(5L, result[1].id) + assertEquals(6L, result[2].id) + assertEquals(2L, result[3].id) + assertEquals(4L, result[4].id) + } + + @Test + fun `execute with forceRefresh true passes correct params`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(true) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(true) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + + assertEquals(1, result.size) + } + + @Test + fun `execute returns empty list when no notifications`() = runTest { + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(emptyList()) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(0, result.size) + } + + @Test + fun `execute maps notification fields correctly`() = runTest { + val notifications = listOf( + AccountNotification( + id = 123L, + subject = "Test Subject", + message = "Test Message", + icon = "warning", + startAt = "1970-01-01T00:00:01Z" + ) + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Canvas University")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(1, result.size) + assertEquals(123L, result[0].id) + assertEquals("Test Subject", result[0].subject) + assertEquals("Test Message", result[0].message) + assertEquals("Canvas University", result[0].institutionName) + assertEquals("warning", result[0].icon) + assertEquals("https://example.com/logo.png", result[0].logoUrl) + } + + @Test(expected = IllegalStateException::class) + fun `execute throws exception when repository fails`() = runTest { + coEvery { + accountNotificationRepository.getAccountNotifications(any()) + } returns DataResult.Fail() + + useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + } + + @Test + fun `execute handles notifications with null startDate`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "No Date", message = "Message 1", icon = "info", startAt = ""), + AccountNotification(id = 2L, subject = "With Date", message = "Message 2", icon = "info", startAt = "1970-01-01T00:00:02Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(2, result.size) + assertEquals(2L, result[0].id) + assertEquals(1L, result[1].id) + assertEquals(null, result[1].startDate) + } + + @Test + fun `execute returns only first 5 items when more than 5 notifications`() = runTest { + val notifications = (1..10).map { + AccountNotification( + id = it.toLong(), + subject = "Announcement $it", + message = "Message $it", + icon = "info", + startAt = "1970-01-01T00:00:${it.toString().padStart(2, '0')}Z" + ) + } + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(5, result.size) + assertEquals(10L, result[0].id) + assertEquals(9L, result[1].id) + assertEquals(8L, result[2].id) + assertEquals(7L, result[3].id) + assertEquals(6L, result[4].id) + } + + @Test + fun `execute handles failed user repository call gracefully`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Fail() + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(1, result.size) + assertEquals("", result[0].institutionName) + } + + @Test + fun `execute uses empty logo URL when ThemePrefs mobileLogoUrl is empty`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + + every { ThemePrefs.mobileLogoUrl } returns "" + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Test Institution")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(1, result.size) + assertEquals("", result[0].logoUrl) + } + + @Test + fun `execute correctly maps institution name from account`() = runTest { + val notifications = listOf( + AccountNotification(id = 1L, subject = "Announcement 1", message = "Message 1", icon = "info", startAt = "1970-01-01T00:00:01Z") + ) + + coEvery { + accountNotificationRepository.getAccountNotifications(false) + } returns DataResult.Success(notifications) + + coEvery { + userRepository.getAccount(false) + } returns DataResult.Success(Account(id = 1L, name = "Instructure University")) + + val result = useCase(LoadInstitutionalAnnouncementsParams(forceRefresh = false)) + + assertEquals(1, result.size) + assertEquals("Instructure University", result[0].institutionName) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/enrollment/HandleCourseInvitationUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/enrollment/HandleCourseInvitationUseCaseTest.kt new file mode 100644 index 0000000000..fd46241070 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/enrollment/HandleCourseInvitationUseCaseTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2025 - 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.enrollment + +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepository +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.Before +import org.junit.Test + +class HandleCourseInvitationUseCaseTest { + + private val enrollmentRepository: EnrollmentRepository = mockk(relaxed = true) + private lateinit var useCase: HandleCourseInvitationUseCase + + @Before + fun setup() { + useCase = HandleCourseInvitationUseCase(enrollmentRepository) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `execute accepts invitation successfully`() = runTest { + coEvery { + enrollmentRepository.handleInvitation(any(), any(), any()) + } returns DataResult.Success(Unit) + + useCase( + HandleCourseInvitationParams( + courseId = 100L, + enrollmentId = 1L, + accept = true + ) + ) + + coVerify { + enrollmentRepository.handleInvitation( + courseId = 100L, + enrollmentId = 1L, + accept = true + ) + } + } + + @Test + fun `execute declines invitation successfully`() = runTest { + coEvery { + enrollmentRepository.handleInvitation(any(), any(), any()) + } returns DataResult.Success(Unit) + + useCase( + HandleCourseInvitationParams( + courseId = 200L, + enrollmentId = 2L, + accept = false + ) + ) + + coVerify { + enrollmentRepository.handleInvitation( + courseId = 200L, + enrollmentId = 2L, + accept = false + ) + } + } + + @Test(expected = IllegalStateException::class) + fun `execute throws exception when repository fails`() = runTest { + coEvery { + enrollmentRepository.handleInvitation(any(), any(), any()) + } returns DataResult.Fail() + + useCase( + HandleCourseInvitationParams( + courseId = 100L, + enrollmentId = 1L, + accept = true + ) + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/enrollment/LoadCourseInvitationsUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/enrollment/LoadCourseInvitationsUseCaseTest.kt new file mode 100644 index 0000000000..542fec577c --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/enrollment/LoadCourseInvitationsUseCaseTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2025 - 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.enrollment + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.pandautils.data.repository.course.CourseRepository +import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepository +import io.mockk.coEvery +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.Before +import org.junit.Test + +class LoadCourseInvitationsUseCaseTest { + + private val enrollmentRepository: EnrollmentRepository = mockk(relaxed = true) + private val courseRepository: CourseRepository = mockk(relaxed = true) + private lateinit var useCase: LoadCourseInvitationsUseCase + + @Before + fun setup() { + useCase = LoadCourseInvitationsUseCase(enrollmentRepository, courseRepository) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `execute returns course invitations successfully`() = runTest { + val enrollments = listOf( + Enrollment(id = 1L, courseId = 100L, userId = 10L), + Enrollment(id = 2L, courseId = 200L, userId = 10L) + ) + val course1 = Course(id = 100L, name = "Course 1") + val course2 = Course(id = 200L, name = "Course 2") + + coEvery { + enrollmentRepository.getSelfEnrollments(null, listOf(EnrollmentAPI.STATE_INVITED), false) + } returns DataResult.Success(enrollments) + coEvery { courseRepository.getCourse(100L, false) } returns DataResult.Success(course1) + coEvery { courseRepository.getCourse(200L, false) } returns DataResult.Success(course2) + + val result = useCase(LoadCourseInvitationsParams(forceRefresh = false)) + + assertEquals(2, result.size) + assertEquals(1L, result[0].enrollmentId) + assertEquals(100L, result[0].courseId) + assertEquals("Course 1", result[0].courseName) + assertEquals(10L, result[0].userId) + assertEquals(2L, result[1].enrollmentId) + assertEquals(200L, result[1].courseId) + assertEquals("Course 2", result[1].courseName) + assertEquals(10L, result[1].userId) + } + + @Test + fun `execute with forceRefresh true passes correct params`() = runTest { + val enrollments = listOf( + Enrollment(id = 1L, courseId = 100L, userId = 10L) + ) + val course = Course(id = 100L, name = "Course 1") + + coEvery { + enrollmentRepository.getSelfEnrollments(null, listOf(EnrollmentAPI.STATE_INVITED), true) + } returns DataResult.Success(enrollments) + coEvery { courseRepository.getCourse(100L, true) } returns DataResult.Success(course) + + val result = useCase(LoadCourseInvitationsParams(forceRefresh = true)) + + assertEquals(1, result.size) + } + + @Test + fun `execute returns empty list when no enrollments`() = runTest { + coEvery { + enrollmentRepository.getSelfEnrollments(null, listOf(EnrollmentAPI.STATE_INVITED), false) + } returns DataResult.Success(emptyList()) + + val result = useCase(LoadCourseInvitationsParams(forceRefresh = false)) + + assertEquals(0, result.size) + } + + @Test(expected = IllegalStateException::class) + fun `execute throws exception when enrollment repository fails`() = runTest { + coEvery { + enrollmentRepository.getSelfEnrollments(any(), any(), any()) + } returns DataResult.Fail() + + useCase(LoadCourseInvitationsParams(forceRefresh = false)) + } + + @Test(expected = IllegalStateException::class) + fun `execute throws exception when course repository fails`() = runTest { + val enrollments = listOf( + Enrollment(id = 1L, courseId = 100L, userId = 10L) + ) + + coEvery { + enrollmentRepository.getSelfEnrollments(null, listOf(EnrollmentAPI.STATE_INVITED), false) + } returns DataResult.Success(enrollments) + coEvery { courseRepository.getCourse(100L, false) } returns DataResult.Fail() + + useCase(LoadCourseInvitationsParams(forceRefresh = false)) + } + + @Test(expected = IllegalStateException::class) + fun `execute throws exception when any course repository fails`() = runTest { + val enrollments = listOf( + Enrollment(id = 1L, courseId = 100L, userId = 10L), + Enrollment(id = 2L, courseId = 200L, userId = 10L) + ) + val course1 = Course(id = 100L, name = "Course 1") + + coEvery { + enrollmentRepository.getSelfEnrollments(null, listOf(EnrollmentAPI.STATE_INVITED), false) + } returns DataResult.Success(enrollments) + coEvery { courseRepository.getCourse(100L, false) } returns DataResult.Success(course1) + coEvery { courseRepository.getCourse(200L, false) } returns DataResult.Fail() + + useCase(LoadCourseInvitationsParams(forceRefresh = false)) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsViewModelTest.kt index 3e82a96885..6143c1eccd 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/assignments/details/AssignmentDetailsViewModelTest.kt @@ -36,6 +36,7 @@ import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.models.DiscussionTopicHeader import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.models.ExternalToolAttributes import com.instructure.canvasapi2.models.LockInfo import com.instructure.canvasapi2.models.Quiz import com.instructure.canvasapi2.models.SubAssignmentSubmission @@ -670,12 +671,15 @@ class AssignmentDetailsViewModelTest { } @Test - fun `Submit button click - external tool`() { + fun `Submit button click - external tool with valid LTI tool`() { val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course val assignment = Assignment(submissionTypesRaw = listOf("external_tool")) - coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment.copy( + externalToolAttributes = ExternalToolAttributes(contentId = 1L) + ) + coEvery { assignmentDetailsRepository.getExternalToolLaunchUrl(any(), any(), any(), any()) } returns mockk(relaxed = true) val viewModel = getViewModel() viewModel.onSubmitButtonClicked() @@ -683,6 +687,26 @@ class AssignmentDetailsViewModelTest { assertTrue(viewModel.events.value?.peekContent() is AssignmentDetailAction.NavigateToLtiLaunchScreen) } + @Test + fun `Submit button click - external tool with null LTI tool shows error`() { + val errorMessage = "An unexpected error occurred." + every { resources.getString(R.string.generalUnexpectedError) } returns errorMessage + + val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student))) + coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course + + val assignment = Assignment(submissionTypesRaw = listOf("external_tool")) + coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment + coEvery { assignmentDetailsRepository.getExternalToolLaunchUrl(any(), any(), any(), any()) } returns null + + val viewModel = getViewModel() + viewModel.onSubmitButtonClicked() + + val action = viewModel.events.value?.peekContent() + assertTrue(action is AssignmentDetailAction.ShowToast) + assertEquals(errorMessage, (action as AssignmentDetailAction.ShowToast).message) + } + @Test fun `Create viewData with points when quantitative data is not restricted`() { coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns Course( @@ -752,12 +776,12 @@ class AssignmentDetailsViewModelTest { } @Test - fun `Submit button is not visible when not between valid date range`() { + fun `Submit button is not visible when not between valid date range and assignment is locked`() { val course = Course(enrollments = mutableListOf(Enrollment(type = Enrollment.EnrollmentType.Student)), accessRestrictedByDate = true) every { savedStateHandle.get(Const.CANVAS_CONTEXT) } returns course coEvery { assignmentDetailsRepository.getCourseWithGrade(any(), any()) } returns course - val assignment = Assignment(name = "Test", submissionTypesRaw = listOf("online_text_entry")) + val assignment = Assignment(name = "Test", submissionTypesRaw = listOf("online_text_entry"), lockedForUser = true, hasOverrides = false) coEvery { assignmentDetailsRepository.getAssignment(any(), any(), any(), any()) } returns assignment val viewModel = getViewModel() diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/assignments/list/AssignmentListViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/assignments/list/AssignmentListViewModelTest.kt index b9e7d08ac3..472f6728dc 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/assignments/list/AssignmentListViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/assignments/list/AssignmentListViewModelTest.kt @@ -655,7 +655,7 @@ class AssignmentListViewModelTest { DiscussionCheckpointUiState( name = "Additional replies (3)", dueDate = "No due date", - submissionStateLabel = SubmissionStateLabel.None, + submissionStateLabel = SubmissionStateLabel.NotSubmitted, displayGrade = DisplayGrade(), pointsPossible = 5 ) @@ -890,11 +890,13 @@ class AssignmentListViewModelTest { subAssignmentSubmissions = arrayListOf( SubAssignmentSubmission( grade = "A", - subAssignmentTag = Const.REPLY_TO_TOPIC + subAssignmentTag = Const.REPLY_TO_TOPIC, + submittedAt = Date() ), SubAssignmentSubmission( grade = null, // Not graded - subAssignmentTag = Const.REPLY_TO_ENTRY + subAssignmentTag = Const.REPLY_TO_ENTRY, + submittedAt = Date() // Submitted but not graded ) ) ) @@ -954,12 +956,14 @@ class AssignmentListViewModelTest { SubAssignmentSubmission( grade = null, customGradeStatusId = null, - subAssignmentTag = Const.REPLY_TO_TOPIC + subAssignmentTag = Const.REPLY_TO_TOPIC, + submittedAt = null // Not submitted ), SubAssignmentSubmission( grade = "A", customGradeStatusId = null, - subAssignmentTag = Const.REPLY_TO_ENTRY + subAssignmentTag = Const.REPLY_TO_ENTRY, + submittedAt = Date() // Submitted and graded ) ) ) diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendar/CalendarSharedEventsTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendar/CalendarSharedEventsTest.kt index 48a020fcee..ed4f5a0353 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendar/CalendarSharedEventsTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/calendar/CalendarSharedEventsTest.kt @@ -80,4 +80,16 @@ class CalendarSharedEventsTest { val expectedEvent = SharedCalendarAction.FiltersClosed(false) assertEquals(expectedEvent, event) } + + @Test + fun `Send event when selecting a day from navigation`() = runTest { + val selectedDate = LocalDate.of(2025, 1, 15) + + sharedEvents.sendEvent(this, SharedCalendarAction.SelectDay(selectedDate)) + + val event = sharedEvents.events.first() + + val expectedEvent = SharedCalendarAction.SelectDay(selectedDate) + assertEquals(expectedEvent, event) + } } \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModelTest.kt index 4466a7ac1d..0a91c1f0a2 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/edit/EditDashboardViewModelTest.kt @@ -36,6 +36,7 @@ import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDas import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardGroupItemViewModel import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardHeaderViewModel import com.instructure.pandautils.features.dashboard.edit.itemviewmodels.EditDashboardNoteItemViewModel +import com.instructure.pandautils.features.calendar.CalendarSharedEvents import com.instructure.pandautils.mvvm.ViewState import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery @@ -68,6 +69,7 @@ class EditDashboardViewModelTest { private val groupManager: GroupManager = mockk(relaxed = true) private val repository: EditDashboardRepository = mockk(relaxed = true) private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val calendarSharedEvents: CalendarSharedEvents = mockk(relaxed = true) private val lifecycleOwner: LifecycleOwner = mockk(relaxed = true) private val lifecycleRegistry = LifecycleRegistry(lifecycleOwner) @@ -99,7 +101,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} //Then @@ -116,7 +118,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } throws IllegalStateException() //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} //Then @@ -133,7 +135,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns emptyList() //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -159,7 +161,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -190,7 +192,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -224,7 +226,7 @@ class EditDashboardViewModelTest { every { networkStateProvider.isOnline() } returns false //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -257,7 +259,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -286,7 +288,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -315,7 +317,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -349,7 +351,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -385,7 +387,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -422,7 +424,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -458,7 +460,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -493,7 +495,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -523,7 +525,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -553,7 +555,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -582,7 +584,7 @@ class EditDashboardViewModelTest { coEvery { repository.getGroups() } returns groups //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -644,7 +646,7 @@ class EditDashboardViewModelTest { every { networkStateProvider.isOnline() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -685,7 +687,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -729,7 +731,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -761,7 +763,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -796,7 +798,7 @@ class EditDashboardViewModelTest { } //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} viewModel.events.observe(lifecycleOwner) {} @@ -827,7 +829,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -854,7 +856,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -884,7 +886,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -910,7 +912,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns false //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -936,7 +938,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -961,7 +963,7 @@ class EditDashboardViewModelTest { coEvery { repository.getSyncedCourseIds() } returns setOf(1L) //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -985,7 +987,7 @@ class EditDashboardViewModelTest { coEvery { repository.offlineEnabled() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) viewModel.state.observe(lifecycleOwner) {} viewModel.data.observe(lifecycleOwner) {} @@ -1008,7 +1010,7 @@ class EditDashboardViewModelTest { every { networkStateProvider.isOnline() } returns true //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) val stateUpdates = mutableListOf() viewModel.state.observeForever { stateUpdates.add(it) @@ -1031,7 +1033,7 @@ class EditDashboardViewModelTest { every { networkStateProvider.isOnline() } returns false //When - viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider) + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) val stateUpdates = mutableListOf() viewModel.state.observeForever { stateUpdates.add(it) @@ -1044,6 +1046,64 @@ class EditDashboardViewModelTest { coVerify(exactly = 1) { repository.getCourses() } } + @Test + fun `RefreshToDoList event is sent when course is added to favorites`() { + //Given + val courses = listOf(createCourse(1L, "Current course", false)) + + coEvery { repository.getCourses() } returns listOf(courses, emptyList(), emptyList()) + + every { repository.isFavoriteable(any()) } returns true + + coEvery { repository.getGroups() } returns emptyList() + + every { courseManager.addCourseToFavoritesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(Favorite(1L)) + } + + //When + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) + viewModel.state.observe(lifecycleOwner) {} + viewModel.data.observe(lifecycleOwner) {} + + val data = viewModel.data.value?.items ?: emptyList() + + val itemViewModel = (data[3] as EditDashboardCourseItemViewModel) + itemViewModel.onFavoriteClick() + + //Then + coVerify { calendarSharedEvents.sendEvent(any(), any()) } + } + + @Test + fun `RefreshToDoList event is sent when course is removed from favorites`() { + //Given + val courses = listOf(createCourse(1L, "Current course", true)) + + coEvery { repository.getCourses() } returns listOf(courses, emptyList(), emptyList()) + + every { repository.isFavoriteable(any()) } returns true + + coEvery { repository.getGroups() } returns emptyList() + + every { courseManager.removeCourseFromFavoritesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(Favorite(1L)) + } + + //When + viewModel = EditDashboardViewModel(courseManager, groupManager, repository, networkStateProvider, calendarSharedEvents) + viewModel.state.observe(lifecycleOwner) {} + viewModel.data.observe(lifecycleOwner) {} + + val data = viewModel.data.value?.items ?: emptyList() + + val itemViewModel = (data[3] as EditDashboardCourseItemViewModel) + itemViewModel.onFavoriteClick() + + //Then + coVerify { calendarSharedEvents.sendEvent(any(), any()) } + } + private fun createCourse( id: Long, name: String, diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsViewModelTest.kt new file mode 100644 index 0000000000..83ea60b45d --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsViewModelTest.kt @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2025 - 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.dashboard.widget.courseinvitation + +import android.content.res.Resources +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.instructure.pandautils.R +import com.instructure.pandautils.domain.models.enrollment.CourseInvitation +import com.instructure.pandautils.domain.usecase.enrollment.HandleCourseInvitationParams +import com.instructure.pandautils.domain.usecase.enrollment.HandleCourseInvitationUseCase +import com.instructure.pandautils.domain.usecase.enrollment.LoadCourseInvitationsParams +import com.instructure.pandautils.domain.usecase.enrollment.LoadCourseInvitationsUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class CourseInvitationsViewModelTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + private val lifecycleOwner: LifecycleOwner = mockk(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(lifecycleOwner) + + private val testDispatcher = UnconfinedTestDispatcher() + private val loadCourseInvitationsUseCase: LoadCourseInvitationsUseCase = mockk(relaxed = true) + private val handleCourseInvitationUseCase: HandleCourseInvitationUseCase = mockk(relaxed = true) + private val resources: Resources = mockk(relaxed = true) + + private lateinit var viewModel: CourseInvitationsViewModel + + @Before + fun setUp() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + Dispatchers.setMain(testDispatcher) + setupStrings() + } + + private fun setupStrings() { + every { resources.getString(R.string.courseInvitationAccepted, any()) } returns "Invitation accepted" + every { resources.getString(R.string.courseInvitationDeclined, any()) } returns "Invitation declined" + every { resources.getString(R.string.errorOccurred) } returns "An error occurred" + every { resources.getString(R.string.retry) } returns "Retry" + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `init loads invitations with force refresh`() = runTest { + val invitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L), + CourseInvitation(2L, 200L, "Course 2", 10L) + ) + coEvery { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } returns invitations + + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.loading) + assertFalse(state.error) + assertEquals(2, state.invitations.size) + assertEquals("Course 1", state.invitations[0].courseName) + assertEquals("Course 2", state.invitations[1].courseName) + } + + @Test + fun `init shows error state when loading fails`() = runTest { + coEvery { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } throws Exception("Network error") + + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.loading) + assertTrue(state.error) + assertTrue(state.invitations.isEmpty()) + } + + @Test + fun `onRefresh reloads invitations`() = runTest { + val initialInvitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L) + ) + val refreshedInvitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L), + CourseInvitation(2L, 200L, "Course 2", 10L) + ) + coEvery { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } returns initialInvitations andThen refreshedInvitations + + viewModel = createViewModel() + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.invitations.size) + + viewModel.uiState.value.onRefresh() + advanceUntilIdle() + + assertEquals(2, viewModel.uiState.value.invitations.size) + coVerify(exactly = 2) { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } + } + + @Test + fun `onAcceptInvitation removes invitation optimistically and shows success message`() = runTest { + val invitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L), + CourseInvitation(2L, 200L, "Course 2", 10L) + ) + coEvery { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } returns invitations + coEvery { handleCourseInvitationUseCase(any()) } returns Unit + + viewModel = createViewModel() + advanceUntilIdle() + assertEquals(2, viewModel.uiState.value.invitations.size) + + val onAccept = viewModel.uiState.value.onAcceptInvitation + onAccept(invitations[0]) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(1, state.invitations.size) + assertEquals("Course 2", state.invitations[0].courseName) + assertNotNull(state.snackbarMessage) + assertEquals("Invitation accepted", state.snackbarMessage?.message) + assertNull(state.snackbarMessage?.action) + coVerify { + handleCourseInvitationUseCase( + HandleCourseInvitationParams( + courseId = 100L, + enrollmentId = 1L, + accept = true + ) + ) + } + } + + @Test + fun `onDeclineInvitation removes invitation optimistically and shows success message`() = runTest { + val invitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L), + CourseInvitation(2L, 200L, "Course 2", 10L) + ) + coEvery { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } returns invitations + coEvery { handleCourseInvitationUseCase.invoke(any()) } returns Unit + + viewModel = createViewModel() + assertEquals(2, viewModel.uiState.value.invitations.size) + viewModel.uiState.value.onDeclineInvitation(invitations[1]) + + val state = viewModel.uiState.value + assertEquals(1, state.invitations.size) + assertEquals("Course 1", state.invitations[0].courseName) + assertNotNull(state.snackbarMessage) + assertEquals("Invitation declined", state.snackbarMessage?.message) + assertNull(state.snackbarMessage?.action) + coVerify { + handleCourseInvitationUseCase( + HandleCourseInvitationParams( + courseId = 200L, + enrollmentId = 2L, + accept = false + ) + ) + } + } + + @Test + fun `onAcceptInvitation restores invitation and shows error with retry action on failure`() = runTest { + val invitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L), + CourseInvitation(2L, 200L, "Course 2", 10L) + ) + coEvery { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } returns invitations + coEvery { handleCourseInvitationUseCase(any()) } throws Exception("Network error") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.uiState.value.onAcceptInvitation(invitations[0]) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(2, state.invitations.size) + assertEquals("Course 1", state.invitations[0].courseName) + assertEquals("Course 2", state.invitations[1].courseName) + assertNotNull(state.snackbarMessage) + assertNotNull(state.snackbarMessage?.action) + } + + @Test + fun `onDeclineInvitation restores invitation and shows error with retry action on failure`() = runTest { + val invitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L), + CourseInvitation(2L, 200L, "Course 2", 10L) + ) + coEvery { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } returns invitations + coEvery { handleCourseInvitationUseCase(any()) } throws Exception("Network error") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.uiState.value.onDeclineInvitation(invitations[1]) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(2, state.invitations.size) + assertNotNull(state.snackbarMessage) + assertNotNull(state.snackbarMessage?.action) + } + + @Test + fun `retry action retries the failed invitation action`() = runTest { + val invitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L) + ) + coEvery { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } returns invitations + coEvery { handleCourseInvitationUseCase(any()) } throws Exception("Network error") andThen Unit + + viewModel = createViewModel() + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.invitations.size) + + viewModel.uiState.value.onAcceptInvitation(invitations[0]) + advanceUntilIdle() + + val retryAction = viewModel.uiState.value.snackbarMessage?.action + assertNotNull(retryAction) + + retryAction!!.invoke() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(0, state.invitations.size) + assertNotNull(state.snackbarMessage) + assertNull(state.snackbarMessage?.action) + coVerify(exactly = 2) { + handleCourseInvitationUseCase( + HandleCourseInvitationParams( + courseId = 100L, + enrollmentId = 1L, + accept = true + ) + ) + } + } + + @Test + fun `onClearSnackbar clears snackbar message and action`() = runTest { + val invitations = listOf( + CourseInvitation(1L, 100L, "Course 1", 10L) + ) + coEvery { loadCourseInvitationsUseCase(LoadCourseInvitationsParams(forceRefresh = true)) } returns invitations + coEvery { handleCourseInvitationUseCase(any()) } returns Unit + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.uiState.value.onAcceptInvitation(invitations[0]) + advanceUntilIdle() + + assertNotNull(viewModel.uiState.value.snackbarMessage) + + viewModel.uiState.value.onClearSnackbar() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertNull(state.snackbarMessage) + } + + private fun createViewModel(): CourseInvitationsViewModel { + return CourseInvitationsViewModel(loadCourseInvitationsUseCase, handleCourseInvitationUseCase, resources) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModelTest.kt new file mode 100644 index 0000000000..6363081211 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsViewModelTest.kt @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2025 - 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.dashboard.widget.institutionalannouncements + +import com.instructure.pandautils.domain.models.accountnotification.InstitutionalAnnouncement +import com.instructure.pandautils.domain.usecase.accountnotification.LoadInstitutionalAnnouncementsParams +import com.instructure.pandautils.domain.usecase.accountnotification.LoadInstitutionalAnnouncementsUseCase +import io.mockk.coEvery +import io.mockk.mockk +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.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class InstitutionalAnnouncementsViewModelTest { + + private val loadInstitutionalAnnouncementsUseCase: LoadInstitutionalAnnouncementsUseCase = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Initial load shows loading state then success`() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Test Announcement", + message = "Test Message", + institutionName = "", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns announcements + + val viewModel = createViewModel() + + assertFalse(viewModel.uiState.value.loading) + assertFalse(viewModel.uiState.value.error) + assertEquals(1, viewModel.uiState.value.announcements.size) + assertEquals("Test Announcement", viewModel.uiState.value.announcements[0].subject) + } + + @Test + fun `Load error shows error state`() { + coEvery { + loadInstitutionalAnnouncementsUseCase(any()) + } throws Exception("Network error") + + val viewModel = createViewModel() + + assertFalse(viewModel.uiState.value.loading) + assertTrue(viewModel.uiState.value.error) + assertTrue(viewModel.uiState.value.announcements.isEmpty()) + } + + @Test + fun `Refresh loads announcements with forceRefresh`() { + val initialAnnouncements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Initial", + message = "Message", + institutionName = "", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + val refreshedAnnouncements = listOf( + InstitutionalAnnouncement( + id = 2L, + subject = "Refreshed", + message = "Message", + institutionName = "", + startDate = Date(), + icon = "warning", + logoUrl = "" + ) + ) + + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns initialAnnouncements andThen refreshedAnnouncements + + val viewModel = createViewModel() + + assertEquals("Initial", viewModel.uiState.value.announcements[0].subject) + + viewModel.uiState.value.onRefresh() + + assertEquals("Refreshed", viewModel.uiState.value.announcements[0].subject) + } + + @Test + fun `Empty announcements list returns empty state`() { + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns emptyList() + + val viewModel = createViewModel() + + assertFalse(viewModel.uiState.value.loading) + assertFalse(viewModel.uiState.value.error) + assertTrue(viewModel.uiState.value.announcements.isEmpty()) + } + + @Test + fun `Multiple announcements are loaded correctly`() { + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Announcement 1", + message = "Message 1", + institutionName = "", + startDate = Date(1000L), + icon = "info", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 2L, + subject = "Announcement 2", + message = "Message 2", + institutionName = "", + startDate = Date(2000L), + icon = "warning", + logoUrl = "" + ), + InstitutionalAnnouncement( + id = 3L, + subject = "Announcement 3", + message = "Message 3", + institutionName = "", + startDate = Date(3000L), + icon = "calendar", + logoUrl = "" + ) + ) + + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns announcements + + val viewModel = createViewModel() + + assertFalse(viewModel.uiState.value.loading) + assertFalse(viewModel.uiState.value.error) + assertEquals(3, viewModel.uiState.value.announcements.size) + assertEquals("Announcement 1", viewModel.uiState.value.announcements[0].subject) + assertEquals("Announcement 2", viewModel.uiState.value.announcements[1].subject) + assertEquals("Announcement 3", viewModel.uiState.value.announcements[2].subject) + } + + @Test + fun `Refresh after error recovers to success state`() { + coEvery { + loadInstitutionalAnnouncementsUseCase(any()) + } throws Exception("Network error") + + val viewModel = createViewModel() + + assertTrue(viewModel.uiState.value.error) + assertTrue(viewModel.uiState.value.announcements.isEmpty()) + + val announcements = listOf( + InstitutionalAnnouncement( + id = 1L, + subject = "Recovered", + message = "Message", + institutionName = "", + startDate = Date(), + icon = "info", + logoUrl = "" + ) + ) + + coEvery { + loadInstitutionalAnnouncementsUseCase(LoadInstitutionalAnnouncementsParams(forceRefresh = true)) + } returns announcements + + viewModel.uiState.value.onRefresh() + + assertFalse(viewModel.uiState.value.loading) + assertFalse(viewModel.uiState.value.error) + assertEquals(1, viewModel.uiState.value.announcements.size) + assertEquals("Recovered", viewModel.uiState.value.announcements[0].subject) + } + + private fun createViewModel(): InstitutionalAnnouncementsViewModel { + return InstitutionalAnnouncementsViewModel(loadInstitutionalAnnouncementsUseCase) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/repository/BaseWidgetConfigRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/repository/BaseWidgetConfigRepositoryTest.kt new file mode 100644 index 0000000000..c3f453fca4 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/repository/BaseWidgetConfigRepositoryTest.kt @@ -0,0 +1,146 @@ +/* + * 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.pandautils.features.dashboard.widget.repository + +import com.instructure.pandautils.features.dashboard.widget.WidgetConfig +import com.instructure.pandautils.features.dashboard.widget.db.WidgetConfigDao +import com.instructure.pandautils.features.dashboard.widget.db.WidgetConfigEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class BaseWidgetConfigRepositoryTest { + + private val dao: WidgetConfigDao = mockk(relaxed = true) + private lateinit var repository: TestWidgetConfigRepository + + @Before + fun setup() { + repository = TestWidgetConfigRepository(dao) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `observeConfig returns deserialized config from dao`() = runTest { + val entity = WidgetConfigEntity("widget1", """{"data":"value"}""") + coEvery { dao.observeConfig("widget1") } returns flowOf(entity) + + val result = repository.observeConfig("widget1").first() + + assertEquals("widget1", result.widgetId) + assertEquals("value", result.data) + } + + @Test + fun `observeConfig returns default when entity is null`() = runTest { + coEvery { dao.observeConfig("widget1") } returns flowOf(null) + + val result = repository.observeConfig("widget1").first() + + assertEquals("widget1", result.widgetId) + assertEquals("default", result.data) + } + + @Test + fun `observeConfig returns default when deserialization fails`() = runTest { + val entity = WidgetConfigEntity("widget1", """invalid json""") + coEvery { dao.observeConfig("widget1") } returns flowOf(entity) + + val result = repository.observeConfig("widget1").first() + + // Note: TestWidgetConfigRepository.deserializeConfig returns null for invalid JSON, + // which triggers getDefaultConfig() to be used + assertEquals("widget1", result.widgetId) + assertEquals("default", result.data) + } + + @Test + fun `saveConfig serializes and saves to dao`() = runTest { + val config = TestWidgetConfig("widget1", "test-value") + + repository.saveConfig(config) + + coVerify { + dao.upsertConfig( + match { + it.widgetId == "widget1" && + it.configJson == """{"data":"test-value"}""" + } + ) + } + } + + @Test + fun `deleteConfig removes entity from dao`() = runTest { + val entity = WidgetConfigEntity("widget1", """{"data":"value"}""") + val flow = flowOf(entity) + coEvery { dao.observeConfig("widget1") } returns flow + + repository.deleteConfig("widget1") + + coVerify { dao.deleteConfig(entity) } + } + + @Test + fun `deleteConfig does nothing when entity does not exist`() = runTest { + coEvery { dao.observeConfig("widget1") } returns flowOf(null) + + repository.deleteConfig("widget1") + + coVerify(exactly = 0) { dao.deleteConfig(any()) } + } + + // Test implementations + data class TestWidgetConfig( + override val widgetId: String, + val data: String + ) : WidgetConfig { + override fun toJson(): String = """{"data":"$data"}""" + } + + class TestWidgetConfigRepository(dao: WidgetConfigDao) : + BaseWidgetConfigRepository(dao) { + + override fun deserializeConfig(json: String): TestWidgetConfig? { + return try { + // Simple JSON parsing - expects {"data":"value"} + if (!json.contains("{") || !json.contains("}")) return null + val data = json.substringAfter("\":\"").substringBefore("\"}") + if (data.isEmpty() || data == json) return null + TestWidgetConfig("widget1", data) + } catch (e: Exception) { + null + } + } + + override fun getDefaultConfig(): TestWidgetConfig { + return TestWidgetConfig("widget1", "default") + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/repository/WidgetMetadataRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/repository/WidgetMetadataRepositoryTest.kt new file mode 100644 index 0000000000..e8c7282238 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/repository/WidgetMetadataRepositoryTest.kt @@ -0,0 +1,120 @@ +/* + * 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.pandautils.features.dashboard.widget.repository + +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.db.WidgetMetadataDao +import com.instructure.pandautils.features.dashboard.widget.db.WidgetMetadataEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class WidgetMetadataRepositoryTest { + + private val dao: WidgetMetadataDao = mockk(relaxed = true) + private lateinit var repository: WidgetMetadataRepository + + @Before + fun setup() { + repository = WidgetMetadataRepositoryImpl(dao) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `observeAllMetadata returns mapped metadata from dao`() = runTest { + val entities = listOf( + WidgetMetadataEntity("widget1", 0, true), + WidgetMetadataEntity("widget2", 1, false) + ) + coEvery { dao.observeAllMetadata() } returns flowOf(entities) + + val result = repository.observeAllMetadata().first() + + assertEquals(2, result.size) + assertEquals("widget1", result[0].id) + assertEquals(0, result[0].position) + assertEquals(true, result[0].isVisible) + assertEquals("widget2", result[1].id) + assertEquals(1, result[1].position) + assertEquals(false, result[1].isVisible) + } + + @Test + fun `observeAllMetadata returns empty list when dao returns empty`() = runTest { + coEvery { dao.observeAllMetadata() } returns flowOf(emptyList()) + + val result = repository.observeAllMetadata().first() + + assertEquals(0, result.size) + } + + @Test + fun `saveMetadata calls dao with mapped entity`() = runTest { + val metadata = WidgetMetadata("widget1", 0, true) + + repository.saveMetadata(metadata) + + coVerify { + dao.upsertMetadata( + WidgetMetadataEntity("widget1", 0, true) + ) + } + } + + @Test + fun `updatePosition calls dao with correct parameters`() = runTest { + repository.updatePosition("widget1", 5) + + coVerify { dao.updatePosition("widget1", 5) } + } + + @Test + fun `updateVisibility calls dao with correct parameters`() = runTest { + repository.updateVisibility("widget1", false) + + coVerify { dao.updateVisibility("widget1", false) } + } + + @Test + fun `saveMetadata preserves all metadata properties`() = runTest { + val metadata = WidgetMetadata("test-widget", 3, false) + + repository.saveMetadata(metadata) + + coVerify { + dao.upsertMetadata( + match { + it.widgetId == "test-widget" && + it.position == 3 && + it.isVisible == false + } + ) + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt new file mode 100644 index 0000000000..8ba93514bc --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt @@ -0,0 +1,128 @@ +/* + * 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.pandautils.features.dashboard.widget.usecase + +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.repository.WidgetMetadataRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +class EnsureDefaultWidgetsUseCaseTest { + + private val repository: WidgetMetadataRepository = mockk(relaxed = true) + private lateinit var useCase: EnsureDefaultWidgetsUseCase + + @Before + fun setup() { + useCase = EnsureDefaultWidgetsUseCase(repository) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `execute creates default widget when database is empty`() = runTest { + coEvery { repository.observeAllMetadata() } returns flowOf(emptyList()) + + useCase(Unit) + + coVerify { + repository.saveMetadata( + match { + it.id == "course_invitations" && it.position == 0 && it.isVisible && !it.isEditable + } + ) + } + coVerify { + repository.saveMetadata( + match { + it.id == "institutional_announcements" && it.position == 1 && it.isVisible && !it.isEditable + } + ) + } + coVerify { + repository.saveMetadata( + match { + it.id == "welcome" && it.position == 2 && it.isVisible + } + ) + } + } + + @Test + fun `execute does not create widget if it already exists`() = runTest { + val existingMetadata = listOf( + WidgetMetadata("course_invitations", 0, true, false), + WidgetMetadata("institutional_announcements", 1, true, false), + WidgetMetadata("welcome", 2, true) + ) + coEvery { repository.observeAllMetadata() } returns flowOf(existingMetadata) + + useCase(Unit) + + coVerify(exactly = 0) { repository.saveMetadata(any()) } + } + + @Test + fun `execute creates only missing widgets`() = runTest { + val existingMetadata = listOf( + WidgetMetadata("other-widget", 0, true) + ) + coEvery { repository.observeAllMetadata() } returns flowOf(existingMetadata) + + useCase(Unit) + + coVerify(exactly = 1) { + repository.saveMetadata( + match { it.id == "course_invitations" } + ) + } + coVerify(exactly = 1) { + repository.saveMetadata( + match { it.id == "institutional_announcements" } + ) + } + coVerify(exactly = 1) { + repository.saveMetadata( + match { it.id == "welcome" } + ) + } + coVerify(exactly = 0) { + repository.saveMetadata( + match { it.id == "other-widget" } + ) + } + } + + @Test + fun `execute creates all default widgets when database is empty`() = runTest { + coEvery { repository.observeAllMetadata() } returns flowOf(emptyList()) + + useCase(Unit) + + coVerify(atLeast = 1) { repository.saveMetadata(any()) } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/ObserveWidgetMetadataUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/ObserveWidgetMetadataUseCaseTest.kt new file mode 100644 index 0000000000..7954607c3e --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/ObserveWidgetMetadataUseCaseTest.kt @@ -0,0 +1,70 @@ +/* + * 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.pandautils.features.dashboard.widget.usecase + +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.repository.WidgetMetadataRepository +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class ObserveWidgetMetadataUseCaseTest { + + private val repository: WidgetMetadataRepository = mockk(relaxed = true) + private lateinit var useCase: ObserveWidgetMetadataUseCase + + @Before + fun setup() { + useCase = ObserveWidgetMetadataUseCase(repository) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `execute returns metadata from repository`() = runTest { + val metadata = listOf( + WidgetMetadata("widget1", 0, true), + WidgetMetadata("widget2", 1, true) + ) + coEvery { repository.observeAllMetadata() } returns flowOf(metadata) + + val result = useCase(Unit).first() + + assertEquals(2, result.size) + assertEquals("widget1", result[0].id) + assertEquals("widget2", result[1].id) + } + + @Test + fun `execute returns empty list when repository has no metadata`() = runTest { + coEvery { repository.observeAllMetadata() } returns flowOf(emptyList()) + + val result = useCase(Unit).first() + + assertEquals(0, result.size) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/SaveWidgetMetadataUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/SaveWidgetMetadataUseCaseTest.kt new file mode 100644 index 0000000000..f1aafcbb45 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/SaveWidgetMetadataUseCaseTest.kt @@ -0,0 +1,69 @@ +/* + * 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.pandautils.features.dashboard.widget.usecase + +import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata +import com.instructure.pandautils.features.dashboard.widget.repository.WidgetMetadataRepository +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +class SaveWidgetMetadataUseCaseTest { + + private val repository: WidgetMetadataRepository = mockk(relaxed = true) + private lateinit var useCase: SaveWidgetMetadataUseCase + + @Before + fun setup() { + useCase = SaveWidgetMetadataUseCase(repository) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `execute saves metadata to repository`() = runTest { + val metadata = WidgetMetadata("widget1", 0, true) + + useCase(metadata) + + coVerify { repository.saveMetadata(metadata) } + } + + @Test + fun `execute saves metadata with correct properties`() = runTest { + val metadata = WidgetMetadata("test-widget", 5, false) + + useCase(metadata) + + coVerify { + repository.saveMetadata( + match { + it.id == "test-widget" && + it.position == 5 && + !it.isVisible + } + ) + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/GetWelcomeGreetingUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/GetWelcomeGreetingUseCaseTest.kt new file mode 100644 index 0000000000..24bb05c231 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/GetWelcomeGreetingUseCaseTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2025 - 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.dashboard.widget.welcome + +import android.content.res.Resources +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.R +import com.instructure.pandautils.features.dashboard.widget.welcome.usecase.GetWelcomeGreetingUseCase +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class GetWelcomeGreetingUseCaseTest { + + private val resources: Resources = mockk() + private val timeOfDayCalculator: TimeOfDayCalculator = mockk() + private val apiPrefs: ApiPrefs = mockk() + + private lateinit var useCase: GetWelcomeGreetingUseCase + + @Before + fun setUp() { + mockkObject(ApiPrefs) + useCase = GetWelcomeGreetingUseCase(resources, timeOfDayCalculator, apiPrefs) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `invoke returns morning greeting with name when user has short name`() { + val user = User(shortName = "Riley") + every { apiPrefs.user } returns user + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.MORNING + every { resources.getString(R.string.welcomeGreetingMorningWithName, "Riley") } returns "Good morning, Riley!" + + val result = useCase() + + assertEquals("Good morning, Riley!", result) + } + + @Test + fun `invoke returns afternoon greeting with name when user has short name`() { + val user = User(shortName = "Riley") + every { apiPrefs.user } returns user + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.AFTERNOON + every { resources.getString(R.string.welcomeGreetingAfternoonWithName, "Riley") } returns "Good afternoon, Riley!" + + val result = useCase() + + assertEquals("Good afternoon, Riley!", result) + } + + @Test + fun `invoke returns evening greeting with name when user has short name`() { + val user = User(shortName = "Riley") + every { apiPrefs.user } returns user + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.EVENING + every { resources.getString(R.string.welcomeGreetingEveningWithName, "Riley") } returns "Good evening, Riley!" + + val result = useCase() + + assertEquals("Good evening, Riley!", result) + } + + @Test + fun `invoke returns night greeting with name when user has short name`() { + val user = User(shortName = "Riley") + every { apiPrefs.user } returns user + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.NIGHT + every { resources.getString(R.string.welcomeGreetingNightWithName, "Riley") } returns "Good night, Riley!" + + val result = useCase() + + assertEquals("Good night, Riley!", result) + } + + @Test + fun `invoke returns morning greeting without name when user has null short name`() { + val user = User(shortName = null) + every { apiPrefs.user } returns user + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.MORNING + every { resources.getString(R.string.welcomeGreetingMorning) } returns "Good morning!" + + val result = useCase() + + assertEquals("Good morning!", result) + } + + @Test + fun `invoke returns morning greeting without name when user has blank short name`() { + val user = User(shortName = " ") + every { apiPrefs.user } returns user + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.MORNING + every { resources.getString(R.string.welcomeGreetingMorning) } returns "Good morning!" + + val result = useCase() + + assertEquals("Good morning!", result) + } + + @Test + fun `invoke returns morning greeting without name when user is null`() { + every { apiPrefs.user } returns null + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.MORNING + every { resources.getString(R.string.welcomeGreetingMorning) } returns "Good morning!" + + val result = useCase() + + assertEquals("Good morning!", result) + } + + @Test + fun `invoke returns afternoon greeting without name when user is null`() { + every { apiPrefs.user } returns null + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.AFTERNOON + every { resources.getString(R.string.welcomeGreetingAfternoon) } returns "Good afternoon!" + + val result = useCase() + + assertEquals("Good afternoon!", result) + } + + @Test + fun `invoke returns evening greeting without name when user is null`() { + every { apiPrefs.user } returns null + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.EVENING + every { resources.getString(R.string.welcomeGreetingEvening) } returns "Good evening!" + + val result = useCase() + + assertEquals("Good evening!", result) + } + + @Test + fun `invoke returns night greeting without name when user is null`() { + every { apiPrefs.user } returns null + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.NIGHT + every { resources.getString(R.string.welcomeGreetingNight) } returns "Good night!" + + val result = useCase() + + assertEquals("Good night!", result) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/GetWelcomeMessageUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/GetWelcomeMessageUseCaseTest.kt new file mode 100644 index 0000000000..b6d1efa130 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/GetWelcomeMessageUseCaseTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2025 - 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.dashboard.widget.welcome + +import android.content.res.Resources +import com.instructure.pandautils.R +import com.instructure.pandautils.features.dashboard.widget.welcome.usecase.GetWelcomeMessageUseCase +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +class GetWelcomeMessageUseCaseTest { + + private val resources: Resources = mockk() + private val timeOfDayCalculator: TimeOfDayCalculator = mockk() + private val random: Random = mockk() + + private val useCase = GetWelcomeMessageUseCase(resources, timeOfDayCalculator, random) + + @Test + fun `invoke returns first generic message when random returns 0 and time is morning`() { + val genericMessages = arrayOf("Generic message 1", "Generic message 2") + val morningMessages = arrayOf("Morning message 1", "Morning message 2") + + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.MORNING + every { resources.getStringArray(R.array.welcomeMessagesGeneric) } returns genericMessages + every { resources.getStringArray(R.array.welcomeMessagesMorning) } returns morningMessages + every { random.nextInt(4) } returns 0 + + val result = useCase() + + assertEquals("Generic message 1", result) + } + + @Test + fun `invoke returns first morning-specific message when random returns 2 and time is morning`() { + val genericMessages = arrayOf("Generic message 1", "Generic message 2") + val morningMessages = arrayOf("Morning message 1", "Morning message 2") + + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.MORNING + every { resources.getStringArray(R.array.welcomeMessagesGeneric) } returns genericMessages + every { resources.getStringArray(R.array.welcomeMessagesMorning) } returns morningMessages + every { random.nextInt(4) } returns 2 + + val result = useCase() + + assertEquals("Morning message 1", result) + } + + @Test + fun `invoke returns afternoon-specific message when random returns 3 and time is afternoon`() { + val genericMessages = arrayOf("Generic message 1", "Generic message 2") + val afternoonMessages = arrayOf("Afternoon message 1", "Afternoon message 2") + + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.AFTERNOON + every { resources.getStringArray(R.array.welcomeMessagesGeneric) } returns genericMessages + every { resources.getStringArray(R.array.welcomeMessagesAfternoon) } returns afternoonMessages + every { random.nextInt(4) } returns 3 + + val result = useCase() + + assertEquals("Afternoon message 2", result) + } + + @Test + fun `invoke returns evening-specific message when random returns 2 and time is evening`() { + val genericMessages = arrayOf("Generic message 1", "Generic message 2") + val eveningMessages = arrayOf("Evening message 1", "Evening message 2") + + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.EVENING + every { resources.getStringArray(R.array.welcomeMessagesGeneric) } returns genericMessages + every { resources.getStringArray(R.array.welcomeMessagesEvening) } returns eveningMessages + every { random.nextInt(4) } returns 2 + + val result = useCase() + + assertEquals("Evening message 1", result) + } + + @Test + fun `invoke returns night-specific message when random returns 3 and time is night`() { + val genericMessages = arrayOf("Generic message 1", "Generic message 2") + val nightMessages = arrayOf("Night message 1", "Night message 2") + + every { timeOfDayCalculator.getTimeOfDay() } returns TimeOfDay.NIGHT + every { resources.getStringArray(R.array.welcomeMessagesGeneric) } returns genericMessages + every { resources.getStringArray(R.array.welcomeMessagesNight) } returns nightMessages + every { random.nextInt(4) } returns 3 + + val result = useCase() + + assertEquals("Night message 2", result) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeOfDayCalculatorTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeOfDayCalculatorTest.kt new file mode 100644 index 0000000000..6ff12064d5 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/TimeOfDayCalculatorTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2025 - 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.dashboard.widget.welcome + +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test + +class TimeOfDayCalculatorTest { + + private val timeProvider: TimeProvider = mockk() + private val calculator = TimeOfDayCalculator(timeProvider) + + @Test + fun `getTimeOfDay returns NIGHT when hour is 0`() { + every { timeProvider.getCurrentHourOfDay() } returns 0 + assertEquals(TimeOfDay.NIGHT, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns NIGHT when hour is 3`() { + every { timeProvider.getCurrentHourOfDay() } returns 3 + assertEquals(TimeOfDay.NIGHT, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns MORNING when hour is 4`() { + every { timeProvider.getCurrentHourOfDay() } returns 4 + assertEquals(TimeOfDay.MORNING, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns MORNING when hour is 8`() { + every { timeProvider.getCurrentHourOfDay() } returns 8 + assertEquals(TimeOfDay.MORNING, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns MORNING when hour is 11`() { + every { timeProvider.getCurrentHourOfDay() } returns 11 + assertEquals(TimeOfDay.MORNING, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns AFTERNOON when hour is 12`() { + every { timeProvider.getCurrentHourOfDay() } returns 12 + assertEquals(TimeOfDay.AFTERNOON, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns AFTERNOON when hour is 14`() { + every { timeProvider.getCurrentHourOfDay() } returns 14 + assertEquals(TimeOfDay.AFTERNOON, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns AFTERNOON when hour is 16`() { + every { timeProvider.getCurrentHourOfDay() } returns 16 + assertEquals(TimeOfDay.AFTERNOON, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns EVENING when hour is 17`() { + every { timeProvider.getCurrentHourOfDay() } returns 17 + assertEquals(TimeOfDay.EVENING, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns EVENING when hour is 19`() { + every { timeProvider.getCurrentHourOfDay() } returns 19 + assertEquals(TimeOfDay.EVENING, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns EVENING when hour is 20`() { + every { timeProvider.getCurrentHourOfDay() } returns 20 + assertEquals(TimeOfDay.EVENING, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns NIGHT when hour is 21`() { + every { timeProvider.getCurrentHourOfDay() } returns 21 + assertEquals(TimeOfDay.NIGHT, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns NIGHT when hour is 22`() { + every { timeProvider.getCurrentHourOfDay() } returns 22 + assertEquals(TimeOfDay.NIGHT, calculator.getTimeOfDay()) + } + + @Test + fun `getTimeOfDay returns NIGHT when hour is 23`() { + every { timeProvider.getCurrentHourOfDay() } returns 23 + assertEquals(TimeOfDay.NIGHT, calculator.getTimeOfDay()) + } +} diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidgetViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidgetViewModelTest.kt new file mode 100644 index 0000000000..ca228f5ed6 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidgetViewModelTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2025 - 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.dashboard.widget.welcome + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.instructure.pandautils.features.dashboard.widget.welcome.usecase.GetWelcomeGreetingUseCase +import com.instructure.pandautils.features.dashboard.widget.welcome.usecase.GetWelcomeMessageUseCase +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +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.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class WelcomeWidgetViewModelTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = UnconfinedTestDispatcher() + private val getWelcomeGreetingUseCase: GetWelcomeGreetingUseCase = mockk() + private val getWelcomeMessageUseCase: GetWelcomeMessageUseCase = mockk() + + private lateinit var viewModel: WelcomeWidgetViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `init loads greeting and message`() { + every { getWelcomeGreetingUseCase() } returns "Good morning, Riley!" + every { getWelcomeMessageUseCase() } returns "Every small step you take is progress." + + viewModel = createViewModel() + + val state = viewModel.uiState.value + assertEquals("Good morning, Riley!", state.greeting) + assertEquals("Every small step you take is progress.", state.message) + verify(exactly = 1) { getWelcomeGreetingUseCase() } + verify(exactly = 1) { getWelcomeMessageUseCase() } + } + + @Test + fun `init loads greeting without name when user has no short name`() { + every { getWelcomeGreetingUseCase() } returns "Good morning!" + every { getWelcomeMessageUseCase() } returns "Start your day with purpose." + + viewModel = createViewModel() + + val state = viewModel.uiState.value + assertEquals("Good morning!", state.greeting) + assertEquals("Start your day with purpose.", state.message) + } + + @Test + fun `init loads afternoon greeting`() { + every { getWelcomeGreetingUseCase() } returns "Good afternoon, Riley!" + every { getWelcomeMessageUseCase() } returns "Keep up the great work." + + viewModel = createViewModel() + + val state = viewModel.uiState.value + assertEquals("Good afternoon, Riley!", state.greeting) + assertEquals("Keep up the great work.", state.message) + } + + @Test + fun `init loads evening greeting`() { + every { getWelcomeGreetingUseCase() } returns "Good evening, Riley!" + every { getWelcomeMessageUseCase() } returns "Finish strong today." + + viewModel = createViewModel() + + val state = viewModel.uiState.value + assertEquals("Good evening, Riley!", state.greeting) + assertEquals("Finish strong today.", state.message) + } + + @Test + fun `init loads night greeting`() { + every { getWelcomeGreetingUseCase() } returns "Good night, Riley!" + every { getWelcomeMessageUseCase() } returns "Rest well, you earned it." + + viewModel = createViewModel() + + val state = viewModel.uiState.value + assertEquals("Good night, Riley!", state.greeting) + assertEquals("Rest well, you earned it.", state.message) + } + + @Test + fun `refresh updates greeting and message`() { + every { getWelcomeGreetingUseCase() } returns "Good morning, Riley!" + every { getWelcomeMessageUseCase() } returns "First message" + + viewModel = createViewModel() + + val initialState = viewModel.uiState.value + assertEquals("Good morning, Riley!", initialState.greeting) + assertEquals("First message", initialState.message) + + every { getWelcomeGreetingUseCase() } returns "Good morning, Riley!" + every { getWelcomeMessageUseCase() } returns "Second message" + + viewModel.refresh() + + val refreshedState = viewModel.uiState.value + assertEquals("Good morning, Riley!", refreshedState.greeting) + assertEquals("Second message", refreshedState.message) + verify(exactly = 2) { getWelcomeGreetingUseCase() } + verify(exactly = 2) { getWelcomeMessageUseCase() } + } + + @Test + fun `refresh updates greeting when time changes`() { + every { getWelcomeGreetingUseCase() } returns "Good morning, Riley!" + every { getWelcomeMessageUseCase() } returns "Morning message" + + viewModel = createViewModel() + + val initialState = viewModel.uiState.value + assertEquals("Good morning, Riley!", initialState.greeting) + + every { getWelcomeGreetingUseCase() } returns "Good afternoon, Riley!" + every { getWelcomeMessageUseCase() } returns "Afternoon message" + + viewModel.refresh() + + val refreshedState = viewModel.uiState.value + assertEquals("Good afternoon, Riley!", refreshedState.greeting) + assertEquals("Afternoon message", refreshedState.message) + } + + @Test + fun `multiple refresh calls update state correctly`() { + every { getWelcomeGreetingUseCase() } returns "Good morning, Riley!" + every { getWelcomeMessageUseCase() } returnsMany listOf( + "Message 1", + "Message 2", + "Message 3" + ) + + viewModel = createViewModel() + + assertEquals("Message 1", viewModel.uiState.value.message) + + viewModel.refresh() + assertEquals("Message 2", viewModel.uiState.value.message) + + viewModel.refresh() + assertEquals("Message 3", viewModel.uiState.value.message) + + verify(exactly = 3) { getWelcomeGreetingUseCase() } + verify(exactly = 3) { getWelcomeMessageUseCase() } + } + + @Test + fun `uiState initial values are empty strings`() { + every { getWelcomeGreetingUseCase() } returns "Good morning, Riley!" + every { getWelcomeMessageUseCase() } returns "Test message" + + // Create ViewModel but check state before init completes would show empty strings + // However, since init runs immediately, we verify the pattern is correct + viewModel = createViewModel() + + // After init, state should be populated + val state = viewModel.uiState.value + assertEquals("Good morning, Riley!", state.greeting) + assertEquals("Test message", state.message) + } + + private fun createViewModel(): WelcomeWidgetViewModel { + return WelcomeWidgetViewModel( + getWelcomeGreetingUseCase = getWelcomeGreetingUseCase, + getWelcomeMessageUseCase = getWelcomeMessageUseCase + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/grades/GradesViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/grades/GradesViewModelTest.kt index 698b44c836..6c46f8213b 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/grades/GradesViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/grades/GradesViewModelTest.kt @@ -460,7 +460,7 @@ class GradesViewModelTest { DiscussionCheckpointUiState( name = "Additional replies (3)", dueDate = getFormattedDate(today.plusDays(2)), - submissionStateLabel = SubmissionStateLabel.None, + submissionStateLabel = SubmissionStateLabel.NotSubmitted, displayGrade = DisplayGrade(), pointsPossible = 5 ) @@ -699,7 +699,7 @@ class GradesViewModelTest { DiscussionCheckpointUiState( name = "Additional replies (3)", dueDate = "No due date", - submissionStateLabel = SubmissionStateLabel.None, + submissionStateLabel = SubmissionStateLabel.NotSubmitted, displayGrade = DisplayGrade(), pointsPossible = 5 ) diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListRepositoryTest.kt new file mode 100644 index 0000000000..41aca37f2b --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListRepositoryTest.kt @@ -0,0 +1,410 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.PlannerAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Plannable +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.models.PlannerOverride +import com.instructure.canvasapi2.utils.DataResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +class ToDoListRepositoryTest { + + private val plannerApi: PlannerAPI.PlannerInterface = mockk(relaxed = true) + private val courseApi: CourseAPI.CoursesInterface = mockk(relaxed = true) + + private lateinit var repository: ToDoListRepository + + @Before + fun setup() { + repository = ToDoListRepository(plannerApi, courseApi) + } + + // getPlannerItems tests + @Test + fun `getPlannerItems returns success with data`() = runTest { + val startDate = "2025-01-01" + val endDate = "2025-01-31" + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1"), + createPlannerItem(id = 2L, title = "Assignment 2") + ) + + coEvery { + plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = any() + ) + } returns DataResult.Success(plannerItems) + + val result = repository.getPlannerItems(startDate, endDate, forceRefresh = false) + + assertTrue(result is DataResult.Success) + assertEquals(2, result.dataOrNull?.size) + assertEquals("Assignment 1", result.dataOrNull?.get(0)?.plannable?.title) + } + + @Test + fun `getPlannerItems returns failure when API call fails`() = runTest { + val startDate = "2025-01-01" + val endDate = "2025-01-31" + + coEvery { + plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = any() + ) + } returns DataResult.Fail() + + val result = repository.getPlannerItems(startDate, endDate, forceRefresh = false) + + assertTrue(result is DataResult.Fail) + } + + @Test + fun `getPlannerItems uses correct RestParams when forceRefresh is true`() = runTest { + val startDate = "2025-01-01" + val endDate = "2025-01-31" + + coEvery { + plannerApi.getPlannerItems( + startDate = any(), + endDate = any(), + contextCodes = any(), + restParams = any() + ) + } returns DataResult.Success(emptyList()) + + repository.getPlannerItems(startDate, endDate, forceRefresh = true) + + coVerify { + plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = match { it.isForceReadFromNetwork && it.usePerPageQueryParam } + ) + } + } + + @Test + fun `getPlannerItems uses correct RestParams when forceRefresh is false`() = runTest { + val startDate = "2025-01-01" + val endDate = "2025-01-31" + + coEvery { + plannerApi.getPlannerItems( + startDate = any(), + endDate = any(), + contextCodes = any(), + restParams = any() + ) + } returns DataResult.Success(emptyList()) + + repository.getPlannerItems(startDate, endDate, forceRefresh = false) + + coVerify { + plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = match { !it.isForceReadFromNetwork && it.usePerPageQueryParam } + ) + } + } + + // getCourses tests + @Test + fun `getCourses returns success with data`() = runTest { + val courses = listOf( + Course(id = 1L, name = "Course 1", courseCode = "CS101"), + Course(id = 2L, name = "Course 2", courseCode = "MATH201") + ) + + coEvery { + courseApi.getFirstPageCourses(any()) + } returns DataResult.Success(courses) + + val result = repository.getCourses(forceRefresh = false) + + assertTrue(result is DataResult.Success) + assertEquals(2, result.dataOrNull?.size) + assertEquals("Course 1", result.dataOrNull?.get(0)?.name) + } + + @Test + fun `getCourses returns failure when API call fails`() = runTest { + coEvery { + courseApi.getFirstPageCourses(any()) + } returns DataResult.Fail() + + val result = repository.getCourses(forceRefresh = false) + + assertTrue(result is DataResult.Fail) + } + + @Test + fun `getCourses uses correct RestParams when forceRefresh is true`() = runTest { + coEvery { + courseApi.getFirstPageCourses(any()) + } returns DataResult.Success(emptyList()) + + repository.getCourses(forceRefresh = true) + + coVerify { + courseApi.getFirstPageCourses( + match { it.isForceReadFromNetwork } + ) + } + } + + @Test + fun `getCourses uses correct RestParams when forceRefresh is false`() = runTest { + coEvery { + courseApi.getFirstPageCourses(any()) + } returns DataResult.Success(emptyList()) + + repository.getCourses(forceRefresh = false) + + coVerify { + courseApi.getFirstPageCourses( + match { !it.isForceReadFromNetwork } + ) + } + } + + // updatePlannerOverride tests + @Test + fun `updatePlannerOverride returns success with updated override`() = runTest { + val overrideId = 123L + val override = PlannerOverride( + id = overrideId, + plannableId = 1L, + plannableType = PlannableType.ASSIGNMENT, + markedComplete = true + ) + + coEvery { + plannerApi.updatePlannerOverride( + plannerOverrideId = overrideId, + complete = true, + params = any() + ) + } returns DataResult.Success(override) + + val result = repository.updatePlannerOverride(overrideId, markedComplete = true) + + assertTrue(result is DataResult.Success) + assertEquals(overrideId, result.dataOrNull?.id) + assertEquals(true, result.dataOrNull?.markedComplete) + } + + @Test + fun `updatePlannerOverride returns failure when API call fails`() = runTest { + val overrideId = 123L + + coEvery { + plannerApi.updatePlannerOverride( + plannerOverrideId = overrideId, + complete = false, + params = any() + ) + } returns DataResult.Fail() + + val result = repository.updatePlannerOverride(overrideId, markedComplete = false) + + assertTrue(result is DataResult.Fail) + } + + @Test + fun `updatePlannerOverride always uses forceRefresh`() = runTest { + val overrideId = 123L + + coEvery { + plannerApi.updatePlannerOverride( + plannerOverrideId = any(), + complete = any(), + params = any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + + repository.updatePlannerOverride(overrideId, markedComplete = true) + + coVerify { + plannerApi.updatePlannerOverride( + plannerOverrideId = overrideId, + complete = true, + params = match { it.isForceReadFromNetwork } + ) + } + } + + // createPlannerOverride tests + @Test + fun `createPlannerOverride returns success with created override`() = runTest { + val plannableId = 456L + val plannableType = PlannableType.ASSIGNMENT + val override = PlannerOverride( + id = 789L, + plannableId = plannableId, + plannableType = plannableType, + markedComplete = true + ) + + coEvery { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = any() + ) + } returns DataResult.Success(override) + + val result = repository.createPlannerOverride( + plannableId = plannableId, + plannableType = plannableType, + markedComplete = true + ) + + assertTrue(result is DataResult.Success) + assertEquals(plannableId, result.dataOrNull?.plannableId) + assertEquals(plannableType, result.dataOrNull?.plannableType) + assertEquals(true, result.dataOrNull?.markedComplete) + } + + @Test + fun `createPlannerOverride returns failure when API call fails`() = runTest { + coEvery { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = any() + ) + } returns DataResult.Fail() + + val result = repository.createPlannerOverride( + plannableId = 456L, + plannableType = PlannableType.QUIZ, + markedComplete = false + ) + + assertTrue(result is DataResult.Fail) + } + + @Test + fun `createPlannerOverride passes correct parameters to API`() = runTest { + val plannableId = 456L + val plannableType = PlannableType.DISCUSSION_TOPIC + + coEvery { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + + repository.createPlannerOverride( + plannableId = plannableId, + plannableType = plannableType, + markedComplete = false + ) + + coVerify { + plannerApi.createPlannerOverride( + plannerOverride = match { + it.plannableId == plannableId && + it.plannableType == plannableType && !it.markedComplete + }, + params = match { it.isForceReadFromNetwork } + ) + } + } + + @Test + fun `createPlannerOverride always uses forceRefresh`() = runTest { + coEvery { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + + repository.createPlannerOverride( + plannableId = 456L, + plannableType = PlannableType.PLANNER_NOTE, + markedComplete = true + ) + + coVerify { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = match { it.isForceReadFromNetwork } + ) + } + } + + // Helper function to create test PlannerItem + private fun createPlannerItem( + id: Long, + title: String, + plannableType: PlannableType = PlannableType.ASSIGNMENT + ): PlannerItem { + return PlannerItem( + courseId = 1L, + groupId = null, + userId = null, + contextType = "Course", + contextName = "Test Course", + plannableType = plannableType, + plannable = Plannable( + id = id, + title = title, + courseId = 1L, + groupId = null, + userId = null, + pointsPossible = null, + dueAt = Date(), + assignmentId = null, + todoDate = null, + startAt = null, + endAt = null, + details = null, + allDay = null, + subAssignmentTag = null + ), + plannableDate = Date(), + htmlUrl = null, + submissionState = null, + newActivity = null, + plannerOverride = null, + plannableItemDetails = null + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt new file mode 100644 index 0000000000..96372a2e20 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt @@ -0,0 +1,1609 @@ +/* + * Copyright (C) 2025 - 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.todolist + +import android.content.Context +import android.os.Bundle +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Plannable +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.models.PlannerOverride +import com.instructure.canvasapi2.models.SubmissionState +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.Analytics +import com.instructure.canvasapi2.utils.AnalyticsEventConstants +import com.instructure.canvasapi2.utils.AnalyticsParamConstants +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.pandautils.R +import com.instructure.pandautils.features.calendar.CalendarSharedEvents +import com.instructure.pandautils.features.calendar.SharedCalendarAction +import com.instructure.pandautils.features.todolist.filter.DateRangeSelection +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity +import com.instructure.pandautils.utils.NetworkStateProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.unmockkConstructor +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +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.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class ToDoListViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val context: Context = mockk(relaxed = true) + private val repository: ToDoListRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val firebaseCrashlytics: FirebaseCrashlytics = mockk(relaxed = true) + private val toDoFilterDao: ToDoFilterDao = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val analytics: Analytics = mockk(relaxed = true) + private val toDoListViewModelBehavior: ToDoListViewModelBehavior = mockk(relaxed = true) + private val calendarSharedEvents: CalendarSharedEvents = mockk(relaxed = true) + + private val testUser = User(id = 123L, name = "Test User") + private val testDomain = "test.instructure.com" + + // Track Bundle values for analytics tests + private val bundleStorage = mutableMapOf() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + ContextKeeper.appContext = context + + // Clear bundle storage for each test + bundleStorage.clear() + + // Mock Bundle constructor for analytics tests + mockkConstructor(Bundle::class) + every { anyConstructed().putString(any(), any()) } answers { + val key = firstArg() + val value = secondArg() + bundleStorage[key] = value + } + every { anyConstructed().getString(any()) } answers { + val key = firstArg() + bundleStorage[key] + } + + // Setup default filter DAO and ApiPrefs behavior + every { apiPrefs.user } returns testUser + every { apiPrefs.fullDomain } returns testDomain + + // Mock CalendarSharedEvents.events flow to return empty flow + every { calendarSharedEvents.events } returns MutableSharedFlow() + + // Return a default filter that shows everything (including completed items) + // This prevents tests from accidentally filtering out items + val defaultTestFilter = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + personalTodos = true, + calendarEvents = true, + showCompleted = true, // Important: show completed items in tests by default + favoriteCourses = false, + pastDateRange = DateRangeSelection.FOUR_WEEKS, + futureDateRange = DateRangeSelection.THIS_WEEK + ) + coEvery { toDoFilterDao.findByUser(any(), any()) } returns defaultTestFilter + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkConstructor(Bundle::class) + unmockkAll() + } + + @Test + fun `ViewModel init loads data successfully`() = runTest { + val courses = listOf( + Course(id = 1L, name = "Course 1", courseCode = "CS101"), + Course(id = 2L, name = "Course 2", courseCode = "MATH201") + ) + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", courseId = 1L), + createPlannerItem(id = 2L, title = "Quiz 1", courseId = 2L, plannableType = PlannableType.QUIZ) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.isLoading) + assertFalse(uiState.isRefreshing) + assertFalse(uiState.isError) + assertEquals(2, uiState.itemsByDate.values.flatten().size) + } + + @Test + fun `ViewModel filters out announcements`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", plannableType = PlannableType.ASSIGNMENT), + createPlannerItem(id = 2L, title = "Announcement", plannableType = PlannableType.ANNOUNCEMENT) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val allItems = uiState.itemsByDate.values.flatten() + + assertEquals(1, allItems.size) + assertEquals("Assignment 1", allItems.first().title) + } + + @Test + fun `ViewModel filters out assessment requests`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", plannableType = PlannableType.ASSIGNMENT), + createPlannerItem(id = 2L, title = "Assessment Request", plannableType = PlannableType.ASSESSMENT_REQUEST) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val allItems = uiState.itemsByDate.values.flatten() + + assertEquals(1, allItems.size) + assertEquals("Assignment 1", allItems.first().title) + } + + @Test + fun `ViewModel filters out access restricted courses`() = runTest { + val courses = listOf( + Course(id = 1L, name = "Available Course", courseCode = "CS101", accessRestrictedByDate = false), + Course(id = 2L, name = "Restricted Course", courseCode = "CS102", accessRestrictedByDate = true) + ) + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", courseId = 1L), + createPlannerItem(id = 2L, title = "Assignment 2", courseId = 2L) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + every { context.getString(any(), any()) } returns "CS101" + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val allItems = uiState.itemsByDate.values.flatten() + + // Only assignment from non-restricted course should be present with context label + assertEquals(2, allItems.size) + // First item should have context label from course map + assertTrue(allItems.any { it.title == "Assignment 1" }) + } + + @Test + fun `ViewModel filters out invited courses`() = runTest { + val courses = listOf( + Course(id = 1L, name = "Enrolled Course", courseCode = "CS101", enrollments = mutableListOf()), + Course(id = 2L, name = "Invited Course", courseCode = "CS102", enrollments = mutableListOf(mockk { + every { enrollmentState } returns "invited" + })) + ) + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", courseId = 1L), + createPlannerItem(id = 2L, title = "Assignment 2", courseId = 2L) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + // Should have both assignments, but invited course won't be in course map + assertEquals(2, uiState.itemsByDate.values.flatten().size) + } + + @Test + fun `ViewModel handles error state`() = runTest { + coEvery { repository.getCourses(any()) } returns DataResult.Fail() + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Fail() + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertTrue(uiState.isError) + assertFalse(uiState.isLoading) + assertFalse(uiState.isRefreshing) + } + + @Test + fun `ViewModel handles exception during load`() = runTest { + coEvery { repository.getCourses(any()) } throws RuntimeException("Test error") + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertTrue(uiState.isError) + assertFalse(uiState.isLoading) + assertFalse(uiState.isRefreshing) + } + + @Test + fun `ViewModel groups items by date`() = runTest { + val date1 = Date(1704067200000L) // Jan 1, 2024 + val date2 = Date(1704153600000L) // Jan 2, 2024 + + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", plannableDate = date1), + createPlannerItem(id = 2L, title = "Assignment 2", plannableDate = date1), + createPlannerItem(id = 3L, title = "Assignment 3", plannableDate = date2) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertEquals(2, uiState.itemsByDate.keys.size) + } + + @Test + fun `ViewModel maps item types correctly`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment", plannableType = PlannableType.ASSIGNMENT), + createPlannerItem(id = 2L, title = "Quiz", plannableType = PlannableType.QUIZ), + createPlannerItem(id = 3L, title = "Discussion", plannableType = PlannableType.DISCUSSION_TOPIC), + createPlannerItem(id = 4L, title = "Calendar Event", plannableType = PlannableType.CALENDAR_EVENT), + createPlannerItem(id = 5L, title = "Planner Note", plannableType = PlannableType.PLANNER_NOTE), + createPlannerItem(id = 6L, title = "Sub Assignment", plannableType = PlannableType.SUB_ASSIGNMENT) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val allItems = uiState.itemsByDate.values.flatten() + + assertEquals(6, allItems.size) + assertEquals(ToDoItemType.ASSIGNMENT, allItems.find { it.title == "Assignment" }?.itemType) + assertEquals(ToDoItemType.QUIZ, allItems.find { it.title == "Quiz" }?.itemType) + assertEquals(ToDoItemType.DISCUSSION, allItems.find { it.title == "Discussion" }?.itemType) + assertEquals(ToDoItemType.CALENDAR_EVENT, allItems.find { it.title == "Calendar Event" }?.itemType) + assertEquals(ToDoItemType.PLANNER_NOTE, allItems.find { it.title == "Planner Note" }?.itemType) + assertEquals(ToDoItemType.SUB_ASSIGNMENT, allItems.find { it.title == "Sub Assignment" }?.itemType) + } + + @Test + fun `ViewModel sets isChecked true for submitted assignments`() = runTest { + val plannerItem = createPlannerItem( + id = 1L, + title = "Submitted Assignment", + plannableType = PlannableType.ASSIGNMENT, + submitted = true + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertTrue(item.isChecked) + } + + @Test + fun `ViewModel sets isChecked false for unsubmitted assignments`() = runTest { + val plannerItem = createPlannerItem( + id = 1L, + title = "Unsubmitted Assignment", + plannableType = PlannableType.ASSIGNMENT, + submitted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertFalse(item.isChecked) + } + + // Callback tests + @Test + fun `onRefresh callback triggers data reload with forceRefresh`() = runTest { + val courses = listOf(Course(id = 1L, name = "Course 1", courseCode = "CS101")) + val initialPlannerItems = listOf(createPlannerItem(id = 1L, title = "Assignment 1")) + val refreshedPlannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1"), + createPlannerItem(id = 2L, title = "Assignment 2") + ) + + coEvery { repository.getCourses(false) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), false) } returns DataResult.Success(initialPlannerItems) + coEvery { repository.getCourses(true) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), true) } returns DataResult.Success(refreshedPlannerItems) + + val viewModel = getViewModel() + + // Verify initial data + val initialUiState = viewModel.uiState.value + assertEquals(1, initialUiState.itemsByDate.values.flatten().size) + assertEquals("Assignment 1", initialUiState.itemsByDate.values.flatten().first().title) + + // Trigger refresh + viewModel.uiState.value.onRefresh() + + // Verify refreshed data + val refreshedUiState = viewModel.uiState.value + assertEquals(2, refreshedUiState.itemsByDate.values.flatten().size) + assertTrue(refreshedUiState.itemsByDate.values.flatten().any { it.title == "Assignment 2" }) + } + + @Test + fun `Empty planner items returns empty state`() = runTest { + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.isLoading) + assertFalse(uiState.isError) + assertTrue(uiState.itemsByDate.isEmpty()) + } + + @Test + fun `Items are sorted by comparison date`() = runTest { + val date1 = Date(1704067200000L) // Earlier date + val date2 = Date(1704153600000L) // Later date + + val plannerItems = listOf( + createPlannerItem(id = 2L, title = "Later Assignment", plannableDate = date2), + createPlannerItem(id = 1L, title = "Earlier Assignment", plannableDate = date1) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val dates = uiState.itemsByDate.keys.toList() + + // Dates should be sorted (earlier date first) + assertTrue(dates.size == 2) + } + + // Todo count tests + @Test + fun `ViewModel calculates todo count correctly on initial load`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Unchecked 1", submitted = false), + createPlannerItem(id = 2L, title = "Checked", submitted = true), + createPlannerItem(id = 3L, title = "Unchecked 2", submitted = false) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertEquals(2, uiState.toDoCount) + } + + @Test + fun `ViewModel emits zero todo count when all items are checked`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Checked 1", submitted = true), + createPlannerItem(id = 2L, title = "Checked 2", submitted = true) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertEquals(0, uiState.toDoCount) + } + + @Test + fun `ViewModel emits todo count when all items are unchecked`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Unchecked 1", submitted = false), + createPlannerItem(id = 2L, title = "Unchecked 2", submitted = false), + createPlannerItem(id = 3L, title = "Unchecked 3", submitted = false) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertEquals(3, uiState.toDoCount) + } + + // Checkbox toggle tests + @Test + fun `Checkbox toggle successfully marks item as done`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + val uiState = viewModel.uiState.value + + assertTrue(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("Assignment", uiState.confirmationSnackbarData?.title) + assertTrue(uiState.confirmationSnackbarData?.markedAsDone == true) + coVerify { repository.createPlannerOverride(1L, PlannableType.ASSIGNMENT, true) } + } + + @Test + fun `Checkbox toggle successfully marks item as undone`() = runTest { + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy( + plannerOverride = plannerOverride + ) + val updatedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(false) + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + coVerify { repository.updatePlannerOverride(100L, false) } + } + + @Test + fun `Checkbox toggle shows offline snackbar when device is offline`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + every { networkStateProvider.isOnline() } returns false + every { context.getString(R.string.todoActionOffline) } returns "This action cannot be performed offline" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("This action cannot be performed offline", uiState.snackbarMessage) + } + + @Test + fun `Checkbox toggle reverts on failure`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Fail() + every { networkStateProvider.isOnline() } returns true + every { context.getString(R.string.errorUpdatingToDo) } returns "Error updating to-do" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("Error updating to-do", uiState.snackbarMessage) + } + + // Swipe to done tests + @Test + fun `Swipe to done successfully marks item as done when unchecked`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + val uiState = viewModel.uiState.value + + assertTrue(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("Assignment", uiState.confirmationSnackbarData?.title) + assertTrue(uiState.confirmationSnackbarData?.markedAsDone == true) + } + + @Test + fun `Swipe to done successfully marks item as undone when checked`() = runTest { + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy( + plannerOverride = plannerOverride + ) + val updatedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + } + + @Test + fun `Swipe to done shows offline snackbar when device is offline`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + every { networkStateProvider.isOnline() } returns false + every { context.getString(R.string.todoActionOffline) } returns "This action cannot be performed offline" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals("This action cannot be performed offline", uiState.snackbarMessage) + } + + // Cache invalidation tests + @Test + fun `Cache is invalidated after successfully creating planner override`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + verify { repository.invalidateCachedResponses() } + } + + @Test + fun `Cache is invalidated after successfully updating planner override`() = runTest { + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy( + plannerOverride = plannerOverride + ) + val updatedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(false) + + verify { repository.invalidateCachedResponses() } + } + + @Test + fun `Cache is not invalidated when planner override update fails`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Fail() + every { networkStateProvider.isOnline() } returns true + every { context.getString(R.string.errorUpdatingToDo) } returns "Error updating to-do" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + verify(exactly = 0) { repository.invalidateCachedResponses() } + } + + // Undo tests + @Test + fun `Undo mark as done successfully reverts item to unchecked`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val revertedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(1L, PlannableType.ASSIGNMENT, true) } returns DataResult.Success(plannerOverride) + coEvery { repository.updatePlannerOverride(100L, false) } returns DataResult.Success(revertedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + // First mark as done + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + // Verify marked as done + assertTrue(viewModel.uiState.value.itemsByDate.values.flatten().first().isChecked) + + // Now undo + viewModel.uiState.value.onUndoMarkAsDoneUndoneAction() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.itemsByDate.values.flatten().first().isChecked) + assertEquals(null, uiState.confirmationSnackbarData) + } + + @Test + fun `Todo count updates when item is marked as done`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + assertEquals(1, viewModel.uiState.value.toDoCount) + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + assertEquals(0, viewModel.uiState.value.toDoCount) + } + + @Test + fun `Todo count updates when item is marked as undone`() = runTest { + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy( + plannerOverride = plannerOverride + ) + val updatedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + assertEquals(0, viewModel.uiState.value.toDoCount) + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(false) + + assertEquals(1, viewModel.uiState.value.toDoCount) + } + + // Filter integration tests + @Test + fun `onFiltersChanged with dateFiltersChanged true triggers data reload`() = runTest { + val courses = listOf(Course(id = 1L, name = "Course 1", courseCode = "CS101")) + val initialPlannerItems = listOf(createPlannerItem(id = 1L, title = "Assignment 1")) + val updatedPlannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1"), + createPlannerItem(id = 2L, title = "Assignment 2") + ) + + coEvery { repository.getCourses(false) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), false) } returns DataResult.Success(initialPlannerItems) + coEvery { repository.getCourses(true) } returns DataResult.Success(courses) andThen DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), true) } returns DataResult.Success(updatedPlannerItems) + + val viewModel = getViewModel() + + // Verify initial data + assertEquals(1, viewModel.uiState.value.itemsByDate.values.flatten().size) + + // Trigger filter change with dateFiltersChanged=true + viewModel.uiState.value.onFiltersChanged(true) + + // Verify data was reloaded + coVerify(atLeast = 2) { repository.getCourses(any()) } + coVerify(atLeast = 2) { repository.getPlannerItems(any(), any(), any()) } + verify { toDoListViewModelBehavior.updateWidget(false) } + } + + @Test + fun `onFiltersChanged with dateFiltersChanged false applies filters locally`() = runTest { + val courses = listOf(Course(id = 1L, name = "Course 1", courseCode = "CS101")) + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1"), + createPlannerItem(id = 2L, title = "Assignment 2") + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + // Clear invocation counters after init + clearMocks(repository, toDoListViewModelBehavior, answers = false) + coEvery { repository.getCourses(any()) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + // Trigger filter change with dateFiltersChanged=false + viewModel.uiState.value.onFiltersChanged(false) + + // Verify data was NOT reloaded from repository (no additional calls) + coVerify(exactly = 0) { repository.getCourses(any()) } + coVerify(exactly = 0) { repository.getPlannerItems(any(), any(), any()) } + verify { toDoListViewModelBehavior.updateWidget(false) } + } + + @Test + fun `Swipe to done adds item to removingItemIds when showCompleted is false`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + // Item should be added to removingItemIds since showCompleted=false + assertTrue(viewModel.uiState.value.removingItemIds.contains("1")) + } + + @Test + fun `Swipe to done does NOT add item to removingItemIds when showCompleted is true`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = true + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + // Item should NOT be added to removingItemIds since showCompleted=true + assertFalse(viewModel.uiState.value.removingItemIds.contains("1")) + } + + @Test + fun `Swipe to done removes item from removingItemIds on failure`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Fail() + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + every { context.getString(R.string.errorUpdatingToDo) } returns "Error updating to-do" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + // Item should be removed from removingItemIds since update failed + assertFalse(viewModel.uiState.value.removingItemIds.contains("1")) + } + + @Test + fun `Checkbox toggle does NOT immediately add item to removingItemIds`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + // Item should NOT be immediately added to removingItemIds (debounced) + assertFalse(viewModel.uiState.value.removingItemIds.contains("1")) + } + + @Test + fun `Checkbox debounce timer adds items to removingItemIds after delay`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + // Item should NOT be immediately added + assertFalse(viewModel.uiState.value.removingItemIds.contains("1")) + + // Advance time past debounce delay (1 second) + advanceTimeBy(1100) + + // Now item should be added to removingItemIds + assertTrue(viewModel.uiState.value.removingItemIds.contains("1")) + } + + @Test + fun `Checkbox debounce timer resets when another checkbox action occurs`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", submitted = false), + createPlannerItem(id = 2L, title = "Assignment 2", submitted = false) + ) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val items = viewModel.uiState.value.itemsByDate.values.flatten() + items[0].onCheckboxToggle(true) + + // Advance time partway through debounce + advanceTimeBy(500) + + // Toggle another item - this should reset the timer + items[1].onCheckboxToggle(true) + + // Advance time to where first item would have been added + advanceTimeBy(700) + + // First item should NOT be added yet (timer was reset) + assertFalse(viewModel.uiState.value.removingItemIds.contains("1")) + + // Advance remaining time + advanceTimeBy(400) + + // Now both items should be added + assertTrue(viewModel.uiState.value.removingItemIds.contains("1")) + assertTrue(viewModel.uiState.value.removingItemIds.contains("2")) + } + + @Test + fun `Undo removes item from removingItemIds`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val revertedOverride = plannerOverride.copy(markedComplete = false) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(1L, PlannableType.ASSIGNMENT, true) } returns DataResult.Success(plannerOverride) + coEvery { repository.updatePlannerOverride(100L, false) } returns DataResult.Success(revertedOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + // Mark item as done + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + // Item should be in removingItemIds + assertTrue(viewModel.uiState.value.removingItemIds.contains("1")) + + // Undo + viewModel.uiState.value.onUndoMarkAsDoneUndoneAction() + + // Item should be removed from removingItemIds + assertFalse(viewModel.uiState.value.removingItemIds.contains("1")) + } + + @Test + fun `Data reload clears removingItemIds`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + // Mark item as done + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + // Item should be in removingItemIds + assertTrue(viewModel.uiState.value.removingItemIds.contains("1")) + + // Trigger data reload + viewModel.uiState.value.onRefresh() + + // removingItemIds should be cleared + assertTrue(viewModel.uiState.value.removingItemIds.isEmpty()) + } + + @Test + fun `isFilterApplied is true when personal todos filter is enabled`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + personalTodos = true + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.isFilterApplied) + } + + @Test + fun `isFilterApplied is true when calendar events filter is enabled`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + calendarEvents = true + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.isFilterApplied) + } + + @Test + fun `isFilterApplied is true when show completed filter is enabled`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = true + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.isFilterApplied) + } + + @Test + fun `isFilterApplied is true when favorite courses filter is enabled`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + favoriteCourses = true + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.isFilterApplied) + } + + @Test + fun `isFilterApplied is true when past date range is not default`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + pastDateRange = DateRangeSelection.TWO_WEEKS + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.isFilterApplied) + } + + @Test + fun `isFilterApplied is true when future date range is not default`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + futureDateRange = DateRangeSelection.THREE_WEEKS + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + val viewModel = getViewModel() + + assertTrue(viewModel.uiState.value.isFilterApplied) + } + + @Test + fun `isFilterApplied is false when all filters are default`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + personalTodos = false, + calendarEvents = false, + showCompleted = false, + favoriteCourses = false, + pastDateRange = DateRangeSelection.FOUR_WEEKS, + futureDateRange = DateRangeSelection.THIS_WEEK + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + val viewModel = getViewModel() + + assertFalse(viewModel.uiState.value.isFilterApplied) + } + + // Analytics tracking tests + @Test + fun `Analytics event is logged when item is marked as done`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + verify { analytics.logEvent(AnalyticsEventConstants.TODO_ITEM_MARKED_DONE) } + } + + @Test + fun `Analytics event is logged when item is marked as undone`() = runTest { + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy( + plannerOverride = plannerOverride + ) + val updatedOverride = plannerOverride.copy(markedComplete = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(false) + + verify { analytics.logEvent(AnalyticsEventConstants.TODO_ITEM_MARKED_UNDONE) } + } + + @Test + fun `Analytics event is logged when item is marked as done via swipe`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + verify { analytics.logEvent(AnalyticsEventConstants.TODO_ITEM_MARKED_DONE) } + } + + @Test + fun `Analytics event is not logged when item update fails`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Fail() + every { networkStateProvider.isOnline() } returns true + every { context.getString(R.string.errorUpdatingToDo) } returns "Error updating to-do" + + val viewModel = getViewModel() + + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + verify(exactly = 0) { analytics.logEvent(AnalyticsEventConstants.TODO_ITEM_MARKED_DONE) } + verify(exactly = 0) { analytics.logEvent(AnalyticsEventConstants.TODO_ITEM_MARKED_UNDONE) } + } + + @Test + fun `Analytics event is logged for default filter on init`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + personalTodos = false, + calendarEvents = false, + showCompleted = false, + favoriteCourses = false, + pastDateRange = DateRangeSelection.FOUR_WEEKS, + futureDateRange = DateRangeSelection.THIS_WEEK + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + getViewModel() + + verify { analytics.logEvent(AnalyticsEventConstants.TODO_LIST_LOADED_DEFAULT_FILTER) } + } + + @Test + fun `Analytics event is logged for custom filter with personal todos enabled`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + personalTodos = true, + calendarEvents = false, + showCompleted = false, + favoriteCourses = false, + pastDateRange = DateRangeSelection.ONE_WEEK, + futureDateRange = DateRangeSelection.ONE_WEEK + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + getViewModel() + + val bundleSlot = slot() + verify { + analytics.logEvent( + AnalyticsEventConstants.TODO_LIST_LOADED_CUSTOM_FILTER, + capture(bundleSlot) + ) + } + + assertEquals("true", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_PERSONAL_TODOS)) + assertEquals("false", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_CALENDAR_EVENTS)) + assertEquals("false", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SHOW_COMPLETED)) + assertEquals("false", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_FAVOURITE_COURSES)) + assertEquals("one_week", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SELECTED_DATE_RANGE_PAST)) + assertEquals("one_week", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SELECTED_DATE_RANGE_FUTURE)) + } + + @Test + fun `Analytics event is logged for custom filter with all options enabled`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + personalTodos = true, + calendarEvents = true, + showCompleted = true, + favoriteCourses = true, + pastDateRange = DateRangeSelection.TWO_WEEKS, + futureDateRange = DateRangeSelection.THREE_WEEKS + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + getViewModel() + + val bundleSlot = slot() + verify { + analytics.logEvent( + AnalyticsEventConstants.TODO_LIST_LOADED_CUSTOM_FILTER, + capture(bundleSlot) + ) + } + + assertEquals("true", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_PERSONAL_TODOS)) + assertEquals("true", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_CALENDAR_EVENTS)) + assertEquals("true", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SHOW_COMPLETED)) + assertEquals("true", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_FAVOURITE_COURSES)) + assertEquals("two_weeks", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SELECTED_DATE_RANGE_PAST)) + assertEquals("three_weeks", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SELECTED_DATE_RANGE_FUTURE)) + } + + @Test + fun `Analytics event is logged for custom filter with custom date ranges`() = runTest { + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + personalTodos = false, + calendarEvents = false, + showCompleted = false, + favoriteCourses = false, + pastDateRange = DateRangeSelection.FOUR_WEEKS, + futureDateRange = DateRangeSelection.FOUR_WEEKS + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + + getViewModel() + + val bundleSlot = slot() + verify { + analytics.logEvent( + AnalyticsEventConstants.TODO_LIST_LOADED_CUSTOM_FILTER, + capture(bundleSlot) + ) + } + + assertEquals("false", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_PERSONAL_TODOS)) + assertEquals("false", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_CALENDAR_EVENTS)) + assertEquals("false", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SHOW_COMPLETED)) + assertEquals("false", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_FAVOURITE_COURSES)) + assertEquals("four_weeks", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SELECTED_DATE_RANGE_PAST)) + assertEquals("four_weeks", bundleSlot.captured.getString(AnalyticsParamConstants.FILTER_SELECTED_DATE_RANGE_FUTURE)) + } + + @Test + fun `Account-level calendar events are not clickable`() = runTest { + val accountCalendarEvent = createPlannerItem( + id = 1L, + title = "Account Event", + plannableType = PlannableType.CALENDAR_EVENT + ).copy(contextType = "Account") + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(accountCalendarEvent)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertFalse(item.isClickable) + assertEquals(ToDoItemType.CALENDAR_EVENT, item.itemType) + } + + @Test + fun `Course-level calendar events are clickable`() = runTest { + val courseCalendarEvent = createPlannerItem( + id = 1L, + title = "Course Event", + courseId = 1L, + plannableType = PlannableType.CALENDAR_EVENT + ).copy(contextType = "Course") + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(courseCalendarEvent)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertTrue(item.isClickable) + assertEquals(ToDoItemType.CALENDAR_EVENT, item.itemType) + } + + @Test + fun `User-level calendar events are clickable`() = runTest { + val userCalendarEvent = createPlannerItem( + id = 1L, + title = "User Event", + plannableType = PlannableType.CALENDAR_EVENT + ).copy(contextType = "User") + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(userCalendarEvent)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertTrue(item.isClickable) + assertEquals(ToDoItemType.CALENDAR_EVENT, item.itemType) + } + + @Test + fun `RefreshToDoList event triggers loadData with forceRefresh`() = runTest { + val courses = listOf(Course(id = 1L, name = "Course 1", courseCode = "CS101")) + val initialPlannerItems = listOf(createPlannerItem(id = 1L, title = "Assignment 1")) + val refreshedPlannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1"), + createPlannerItem(id = 2L, title = "Assignment 2") + ) + + coEvery { repository.getCourses(false) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), false) } returns DataResult.Success(initialPlannerItems) + coEvery { repository.getCourses(true) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), true) } returns DataResult.Success(refreshedPlannerItems) + + // Create a real MutableSharedFlow for testing + val sharedEventsFlow = MutableSharedFlow() + every { calendarSharedEvents.events } returns sharedEventsFlow + + val viewModel = getViewModel() + + // Verify initial data + assertEquals(1, viewModel.uiState.value.itemsByDate.values.flatten().size) + + // Emit RefreshToDoList event + sharedEventsFlow.emit(SharedCalendarAction.RefreshToDoList) + + // Verify data was reloaded with forceRefresh=true + coVerify { repository.getCourses(true) } + coVerify { repository.getPlannerItems(any(), any(), true) } + assertEquals(2, viewModel.uiState.value.itemsByDate.values.flatten().size) + } + + @Test + fun `Empty state is shown when completing the last item via swipe`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Last Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + // Verify we start with one item + assertEquals(1, viewModel.uiState.value.itemsByDate.values.flatten().size) + assertEquals(1, viewModel.uiState.value.toDoCount) + + // Complete the last item via swipe + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onSwipeToDone() + + val uiState = viewModel.uiState.value + + // Item should be marked as checked + assertTrue(uiState.itemsByDate.values.flatten().first().isChecked) + // Item should be added to removingItemIds (will be hidden from UI, triggering empty state) + assertTrue(uiState.removingItemIds.contains("1")) + // Todo count should be zero + assertEquals(0, uiState.toDoCount) + } + + @Test + fun `Empty state is shown when completing the last item via checkbox after debounce`() = runTest { + val plannerItem = createPlannerItem(id = 1L, title = "Last Assignment", submitted = false) + val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true) + val filters = ToDoFilterEntity( + userDomain = testDomain, + userId = testUser.id, + showCompleted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns filters + every { networkStateProvider.isOnline() } returns true + + val viewModel = getViewModel() + + // Verify we start with one item and todo count is 1 + assertEquals(1, viewModel.uiState.value.itemsByDate.values.flatten().size) + assertEquals(1, viewModel.uiState.value.toDoCount) + + // Complete the last item via checkbox + val item = viewModel.uiState.value.itemsByDate.values.flatten().first() + item.onCheckboxToggle(true) + + // Todo count should be zero after marking as done + assertEquals(0, viewModel.uiState.value.toDoCount) + // Item should NOT be in removingItemIds yet (debounced) + assertFalse(viewModel.uiState.value.removingItemIds.contains("1")) + + // Advance time past debounce delay + advanceTimeBy(1100) + + // Now item should be added to removingItemIds, which hides it from UI (empty state) + assertTrue(viewModel.uiState.value.removingItemIds.contains("1")) + assertEquals(0, viewModel.uiState.value.toDoCount) + } + + // Helper functions + private fun getViewModel(): ToDoListViewModel { + return ToDoListViewModel(context, repository, networkStateProvider, firebaseCrashlytics, toDoFilterDao, apiPrefs, analytics, toDoListViewModelBehavior, calendarSharedEvents) + } + + private fun createPlannerItem( + id: Long, + title: String, + courseId: Long? = null, + plannableType: PlannableType = PlannableType.ASSIGNMENT, + plannableDate: Date = Date(), + submitted: Boolean = false + ): PlannerItem { + return PlannerItem( + courseId = courseId, + groupId = null, + userId = null, + contextType = if (courseId != null) "Course" else null, + contextName = null, + plannableType = plannableType, + plannable = Plannable( + id = id, + title = title, + courseId = courseId, + groupId = null, + userId = null, + pointsPossible = null, + dueAt = plannableDate, + assignmentId = null, + todoDate = null, + startAt = null, + endAt = null, + details = null, + allDay = null, + subAssignmentTag = null + ), + plannableDate = plannableDate, + htmlUrl = null, + submissionState = if (submitted) SubmissionState(submitted = true) else null, + newActivity = null, + plannerOverride = null, + plannableItemDetails = null + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterViewModelTest.kt new file mode 100644 index 0000000000..fb0893a3f9 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/filter/ToDoFilterViewModelTest.kt @@ -0,0 +1,366 @@ +/* + * Copyright (C) 2025 - 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.todolist.filter + +import android.content.Context +import android.text.format.DateFormat +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.R +import com.instructure.pandautils.room.appdatabase.daos.ToDoFilterDao +import com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.unmockkAll +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.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.Rule +import org.junit.Test +import java.text.SimpleDateFormat + +@OptIn(ExperimentalCoroutinesApi::class) +class ToDoFilterViewModelTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = UnconfinedTestDispatcher() + + private val context: Context = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val toDoFilterDao: ToDoFilterDao = mockk(relaxed = true) + + private lateinit var viewModel: ToDoFilterViewModel + + private val testUser = User(id = 123L, name = "Test User") + private val testDomain = "test.instructure.com" + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + // Mock Android framework classes for date formatting + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } returns "MMM d" + + mockkConstructor(SimpleDateFormat::class) + every { anyConstructed().format(any()) } returns "Jan 1" + + every { apiPrefs.user } returns testUser + every { apiPrefs.fullDomain } returns testDomain + every { context.getString(R.string.todoFilterFromDate, any()) } returns "From Jan 1" + every { context.getString(R.string.todoFilterUntilDate, any()) } returns "Until Jan 1" + every { context.getString(DateRangeSelection.TODAY.pastLabelResId) } returns "Today" + every { context.getString(DateRangeSelection.THIS_WEEK.pastLabelResId) } returns "This Week" + every { context.getString(DateRangeSelection.ONE_WEEK.pastLabelResId) } returns "Last Week" + every { context.getString(DateRangeSelection.TWO_WEEKS.pastLabelResId) } returns "2 Weeks Ago" + every { context.getString(DateRangeSelection.THREE_WEEKS.pastLabelResId) } returns "3 Weeks Ago" + every { context.getString(DateRangeSelection.FOUR_WEEKS.pastLabelResId) } returns "4 Weeks Ago" + every { context.getString(DateRangeSelection.TODAY.futureLabelResId) } returns "Today" + every { context.getString(DateRangeSelection.THIS_WEEK.futureLabelResId) } returns "This Week" + every { context.getString(DateRangeSelection.ONE_WEEK.futureLabelResId) } returns "Next Week" + every { context.getString(DateRangeSelection.TWO_WEEKS.futureLabelResId) } returns "In 2 Weeks" + every { context.getString(DateRangeSelection.THREE_WEEKS.futureLabelResId) } returns "In 3 Weeks" + every { context.getString(DateRangeSelection.FOUR_WEEKS.futureLabelResId) } returns "In 4 Weeks" + + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns null + } + + @After + fun tearDown() { + unmockkAll() + Dispatchers.resetMain() + } + + @Test + fun `Initial state has default values when no saved filters exist`() = runTest { + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + val state = viewModel.uiState.value + + // Default checkbox states should all be false + assertEquals(4, state.checkboxItems.size) + assertFalse(state.checkboxItems[0].checked) // Personal todos + assertFalse(state.checkboxItems[1].checked) // Calendar events + assertFalse(state.checkboxItems[2].checked) // Show completed + assertFalse(state.checkboxItems[3].checked) // Favorite courses + + // Default date range should be FOUR_WEEKS (past) and THIS_WEEK (future) + assertEquals(DateRangeSelection.FOUR_WEEKS, state.selectedPastOption) + assertEquals(DateRangeSelection.THIS_WEEK, state.selectedFutureOption) + + // Flags should be false + assertFalse(state.shouldCloseAndApplyFilters) + assertFalse(state.areDateFiltersChanged) + } + + @Test + fun `Loads saved filters from database on init`() = runTest { + val savedFilters = ToDoFilterEntity( + id = 1, + userDomain = testDomain, + userId = testUser.id, + personalTodos = true, + calendarEvents = true, + showCompleted = false, + favoriteCourses = true, + pastDateRange = DateRangeSelection.TWO_WEEKS, + futureDateRange = DateRangeSelection.THREE_WEEKS + ) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns savedFilters + + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + val state = viewModel.uiState.value + + // Checkbox states should match saved filters + assertTrue(state.checkboxItems[0].checked) // Personal todos + assertTrue(state.checkboxItems[1].checked) // Calendar events + assertFalse(state.checkboxItems[2].checked) // Show completed + assertTrue(state.checkboxItems[3].checked) // Favorite courses + + // Date ranges should match saved filters + assertEquals(DateRangeSelection.TWO_WEEKS, state.selectedPastOption) + assertEquals(DateRangeSelection.THREE_WEEKS, state.selectedFutureOption) + + coVerify { toDoFilterDao.findByUser(testDomain, testUser.id) } + } + + @Test + fun `Checkbox toggle updates state correctly`() = runTest { + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + // Toggle personal todos checkbox + viewModel.uiState.value.checkboxItems[0].onToggle(true) + + // Verify checkbox state updated + val state = viewModel.uiState.value + assertTrue(state.checkboxItems[0].checked) // Personal todos is now checked + assertFalse(state.checkboxItems[1].checked) // Others remain unchanged + } + + @Test + fun `Past date range selection updates state`() = runTest { + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + // Change past date range + viewModel.uiState.value.onPastDaysChanged(DateRangeSelection.THREE_WEEKS) + + // Verify state updated + val state = viewModel.uiState.value + assertEquals(DateRangeSelection.THREE_WEEKS, state.selectedPastOption) + } + + @Test + fun `Future date range selection updates state`() = runTest { + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + // Change future date range + viewModel.uiState.value.onFutureDaysChanged(DateRangeSelection.FOUR_WEEKS) + + // Verify state updated + val state = viewModel.uiState.value + assertEquals(DateRangeSelection.FOUR_WEEKS, state.selectedFutureOption) + } + + @Test + fun `handleDone saves filters to database with correct values`() = runTest { + coEvery { toDoFilterDao.insertOrUpdate(any()) } returns Unit + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns null + + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + // Toggle some checkboxes and change date ranges + viewModel.uiState.value.checkboxItems[0].onToggle(true) // Personal todos + viewModel.uiState.value.checkboxItems[2].onToggle(true) // Show completed + viewModel.uiState.value.onPastDaysChanged(DateRangeSelection.TWO_WEEKS) + viewModel.uiState.value.onFutureDaysChanged(DateRangeSelection.THREE_WEEKS) + + // Click done + viewModel.uiState.value.onDone() + + // Verify saved entity has correct values + coVerify { + toDoFilterDao.insertOrUpdate(match { entity -> + entity.userDomain == testDomain && + entity.userId == testUser.id && + entity.personalTodos && + !entity.calendarEvents && + entity.showCompleted && + !entity.favoriteCourses && + entity.pastDateRange == DateRangeSelection.TWO_WEEKS && + entity.futureDateRange == DateRangeSelection.THREE_WEEKS + }) + } + } + + @Test + fun `handleDone sets shouldCloseAndApplyFilters to true`() = runTest { + coEvery { toDoFilterDao.insertOrUpdate(any()) } returns Unit + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns null + + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + viewModel.uiState.value.onDone() + + assertTrue(viewModel.uiState.value.shouldCloseAndApplyFilters) + } + + @Test + fun `handleDone detects date filter changes when filters are different from saved`() = runTest { + val savedFilters = ToDoFilterEntity( + id = 1, + userDomain = testDomain, + userId = testUser.id, + personalTodos = false, + calendarEvents = false, + showCompleted = false, + favoriteCourses = false, + pastDateRange = DateRangeSelection.ONE_WEEK, + futureDateRange = DateRangeSelection.ONE_WEEK + ) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns savedFilters + coEvery { toDoFilterDao.insertOrUpdate(any()) } returns Unit + + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + // Change date ranges + viewModel.uiState.value.onPastDaysChanged(DateRangeSelection.TWO_WEEKS) + + viewModel.uiState.value.onDone() + + assertTrue(viewModel.uiState.value.areDateFiltersChanged) + } + + @Test + fun `handleDone detects no date filter changes when filters match saved`() = runTest { + val savedFilters = ToDoFilterEntity( + id = 1, + userDomain = testDomain, + userId = testUser.id, + personalTodos = false, + calendarEvents = false, + showCompleted = false, + favoriteCourses = false, + pastDateRange = DateRangeSelection.ONE_WEEK, + futureDateRange = DateRangeSelection.ONE_WEEK + ) + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns savedFilters + coEvery { toDoFilterDao.insertOrUpdate(any()) } returns Unit + + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + // Don't change date ranges, only toggle checkbox + viewModel.uiState.value.checkboxItems[0].onToggle(true) + + viewModel.uiState.value.onDone() + + assertFalse(viewModel.uiState.value.areDateFiltersChanged) + } + + @Test + fun `handleDone marks date filters as changed when no previous filters exist`() = runTest { + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns null + coEvery { toDoFilterDao.insertOrUpdate(any()) } returns Unit + + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + viewModel.uiState.value.onDone() + + // When no saved filters exist, date filters should be considered changed + assertTrue(viewModel.uiState.value.areDateFiltersChanged) + } + + @Test + fun `handleFiltersApplied resets flags`() = runTest { + coEvery { toDoFilterDao.insertOrUpdate(any()) } returns Unit + coEvery { toDoFilterDao.findByUser(testDomain, testUser.id) } returns null + + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + // Trigger done to set flags + viewModel.uiState.value.onDone() + assertTrue(viewModel.uiState.value.shouldCloseAndApplyFilters) + + // Call handleFiltersApplied + viewModel.uiState.value.onFiltersApplied() + + // Flags should be reset + assertFalse(viewModel.uiState.value.shouldCloseAndApplyFilters) + assertFalse(viewModel.uiState.value.areDateFiltersChanged) + } + + @Test + fun `Past date options are created in reversed order`() = runTest { + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + val pastOptions = viewModel.uiState.value.pastDateOptions + + // Should be reversed (FOUR_WEEKS to TODAY) + assertEquals(DateRangeSelection.FOUR_WEEKS, pastOptions[0].selection) + assertEquals(DateRangeSelection.THREE_WEEKS, pastOptions[1].selection) + assertEquals(DateRangeSelection.TWO_WEEKS, pastOptions[2].selection) + assertEquals(DateRangeSelection.ONE_WEEK, pastOptions[3].selection) + assertEquals(DateRangeSelection.THIS_WEEK, pastOptions[4].selection) + assertEquals(DateRangeSelection.TODAY, pastOptions[5].selection) + } + + @Test + fun `Future date options are in normal order`() = runTest { + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + val futureOptions = viewModel.uiState.value.futureDateOptions + + // Should be in order (TODAY to FOUR_WEEKS) + assertEquals(DateRangeSelection.TODAY, futureOptions[0].selection) + assertEquals(DateRangeSelection.THIS_WEEK, futureOptions[1].selection) + assertEquals(DateRangeSelection.ONE_WEEK, futureOptions[2].selection) + assertEquals(DateRangeSelection.TWO_WEEKS, futureOptions[3].selection) + assertEquals(DateRangeSelection.THREE_WEEKS, futureOptions[4].selection) + assertEquals(DateRangeSelection.FOUR_WEEKS, futureOptions[5].selection) + } + + @Test + fun `Multiple checkbox toggles update state correctly`() = runTest { + viewModel = ToDoFilterViewModel(context, apiPrefs, toDoFilterDao) + + // Toggle multiple checkboxes + viewModel.uiState.value.checkboxItems[0].onToggle(true) // Personal todos + viewModel.uiState.value.checkboxItems[1].onToggle(true) // Calendar events + viewModel.uiState.value.checkboxItems[3].onToggle(true) // Favorite courses + + // Verify state updated + val state = viewModel.uiState.value + assertTrue(state.checkboxItems[0].checked) // Personal todos + assertTrue(state.checkboxItems[1].checked) // Calendar events + assertFalse(state.checkboxItems[2].checked) // Show completed (not toggled) + assertTrue(state.checkboxItems[3].checked) // Favorite courses + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/unit/AssignmentUtils2Test.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/unit/AssignmentUtils2Test.kt index 1b45372ade..ca3100cec2 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/unit/AssignmentUtils2Test.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/unit/AssignmentUtils2Test.kt @@ -18,9 +18,14 @@ package com.instructure.pandautils.unit import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.AssignmentDueDate +import com.instructure.canvasapi2.models.AssignmentOverride +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Enrollment import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.toApiString import com.instructure.pandautils.utils.AssignmentUtils2 +import com.instructure.pandautils.utils.isAllowedToSubmitWithOverrides import org.junit.Assert import org.junit.Test import java.util.* @@ -218,4 +223,301 @@ class AssignmentUtils2Test : Assert() { assertEquals("", testValue.toLong(), AssignmentUtils2.ASSIGNMENT_STATE_SUBMITTED.toLong()) } + @Test + fun isAllowedToSubmitWithOverrides_notLockedAssignment_returnsTrue() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = false + ) + val course = Course() + + assertEquals(true, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithNoOverrides_returnsFalse() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = false + ) + val course = Course() + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithNoCourse_returnsFalse() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true + ) + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(null)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithNoEnrollments_returnsFalse() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true + ) + val course = Course(enrollments = null) + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithEmptyEnrollments_returnsFalse() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true + ) + val course = Course(enrollments = mutableListOf()) + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithNoStudentEnrollments_returnsFalse() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true + ) + val course = Course(enrollments = mutableListOf( + Enrollment(courseSectionId = 123L, type = Enrollment.EnrollmentType.Teacher) + )) + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithValidSectionOverride_returnsTrue() { + val currentTime = Calendar.getInstance() + val futureTime = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, 30) } + val futureDate = Date(futureTime.timeInMillis) + + val sectionId = 52878L + val enrollment = Enrollment( + courseSectionId = sectionId, + type = Enrollment.EnrollmentType.Student + ) + + val override = AssignmentOverride( + id = 1L, + courseSectionId = sectionId, + lockAt = futureDate + ) + + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true, + overrides = mutableListOf(override) + ) + + val course = Course(enrollments = mutableListOf(enrollment)) + + assertEquals(true, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithExpiredSectionOverride_returnsFalse() { + val pastTime = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -30) } + val pastDate = Date(pastTime.timeInMillis) + + val sectionId = 52878L + val enrollment = Enrollment( + courseSectionId = sectionId, + type = Enrollment.EnrollmentType.Student + ) + + val override = AssignmentOverride( + id = 1L, + courseSectionId = sectionId, + lockAt = pastDate + ) + + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true, + overrides = mutableListOf(override) + ) + + val course = Course(enrollments = mutableListOf(enrollment)) + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithFutureUnlockDate_returnsFalse() { + val futureUnlockTime = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, 30) } + val futureUnlockDate = Date(futureUnlockTime.timeInMillis) + + val sectionId = 52878L + val enrollment = Enrollment( + courseSectionId = sectionId, + type = Enrollment.EnrollmentType.Student + ) + + val override = AssignmentOverride( + id = 1L, + courseSectionId = sectionId, + unlockAt = futureUnlockDate + ) + + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true, + overrides = mutableListOf(override) + ) + + val course = Course(enrollments = mutableListOf(enrollment)) + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithValidDueDateOverride_returnsTrue() { + val futureTime = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, 30) } + val futureDate = Date(futureTime.timeInMillis) + + val sectionId = 52878L + val enrollment = Enrollment( + courseSectionId = sectionId, + type = Enrollment.EnrollmentType.Student + ) + + val override = AssignmentOverride( + id = 1L, + courseSectionId = sectionId + ) + + val dueDate = AssignmentDueDate( + id = 1L, + lockAt = futureDate.toApiString() + ) + + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true, + overrides = mutableListOf(override), + allDates = mutableListOf(dueDate) + ) + + val course = Course(enrollments = mutableListOf(enrollment)) + + assertEquals(true, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithMultipleSections_firstSectionValid_returnsTrue() { + val futureTime = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, 30) } + val futureDate = Date(futureTime.timeInMillis) + + val section1 = 111L + val section2 = 222L + + val enrollment1 = Enrollment(courseSectionId = section1, type = Enrollment.EnrollmentType.Student) + val enrollment2 = Enrollment(courseSectionId = section2, type = Enrollment.EnrollmentType.Student) + + val override1 = AssignmentOverride(id = 1L, courseSectionId = section1, lockAt = futureDate) + val override2 = AssignmentOverride(id = 2L, courseSectionId = section2) + + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true, + overrides = mutableListOf(override1, override2) + ) + + val course = Course(enrollments = mutableListOf(enrollment1, enrollment2)) + + assertEquals(true, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_quizSubmission_returnsFalse() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_QUIZ.apiString), + lockedForUser = false + ) + val course = Course() + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_attendanceSubmission_returnsFalse() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ATTENDANCE.apiString), + lockedForUser = false + ) + val course = Course() + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_noSubmissionType_returnsFalse() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.NONE.apiString), + lockedForUser = false + ) + val course = Course() + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_onPaperSubmission_returnsFalse() { + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ON_PAPER.apiString), + lockedForUser = false + ) + val course = Course() + + assertEquals(false, assignment.isAllowedToSubmitWithOverrides(course)) + } + + @Test + fun isAllowedToSubmitWithOverrides_lockedWithSectionOverrideMatchingBoth_returnsTrue() { + val pastUnlockTime = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -10) } + val pastUnlockDate = Date(pastUnlockTime.timeInMillis) + + val futureLockTime = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, 10) } + val futureLockDate = Date(futureLockTime.timeInMillis) + + val sectionId = 52878L + val enrollment = Enrollment( + courseSectionId = sectionId, + type = Enrollment.EnrollmentType.Student + ) + + val override = AssignmentOverride( + id = 1L, + courseSectionId = sectionId, + unlockAt = pastUnlockDate, + lockAt = futureLockDate + ) + + val assignment = Assignment( + submissionTypesRaw = listOf(Assignment.SubmissionType.ONLINE_UPLOAD.apiString), + lockedForUser = true, + hasOverrides = true, + overrides = mutableListOf(override) + ) + + val course = Course(enrollments = mutableListOf(enrollment)) + + assertEquals(true, assignment.isAllowedToSubmitWithOverrides(course)) + } + } 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 new file mode 100644 index 0000000000..c46eaa65da --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt @@ -0,0 +1,1074 @@ +/* + * Copyright (C) 2025 - 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.utils + +import android.content.Context +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Plannable +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.models.PlannerItemDetails +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.pandautils.R +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.Calendar +import java.util.Date + +class PlannerItemExtensionsTest { + + private val context: Context = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) + + @Before + fun setup() { + mockkObject(ApiPrefs) + every { ApiPrefs.fullDomain } returns "https://test.instructure.com" + every { apiPrefs.fullDomain } returns "https://example.instructure.com" + + mockkObject(DateHelper) + } + + @After + fun tearDown() { + unmockkAll() + } + + companion object { + // Static dates for predictable testing + private val TEST_DATE = createDate(2025, Calendar.JANUARY, 15, 14, 30) // Jan 15, 2025 at 2:30 PM + private val TEST_DATE_2 = createDate(2025, Calendar.JANUARY, 15, 15, 30) // Jan 15, 2025 at 3:30 PM + + private fun createDate(year: Int, month: Int, day: Int, hour: Int = 0, minute: Int = 0): Date { + return Calendar.getInstance().apply { + set(year, month, day, hour, minute, 0) + set(Calendar.MILLISECOND, 0) + }.time + } + } + + // todoHtmlUrl tests + @Test + fun `todoHtmlUrl returns correct URL`() { + val plannerItem = createPlannerItem(plannableId = 12345L) + + val result = plannerItem.todoHtmlUrl(ApiPrefs) + + assertEquals("https://test.instructure.com/todos/12345", result) + } + + // getIconForPlannerItem tests + @Test + fun `getIconForPlannerItem returns assignment icon for ASSIGNMENT type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.ASSIGNMENT) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_assignment, result) + } + + @Test + fun `getIconForPlannerItem returns quiz icon for QUIZ type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.QUIZ) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_quiz, result) + } + + @Test + fun `getIconForPlannerItem returns calendar icon for CALENDAR_EVENT type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.CALENDAR_EVENT) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_calendar, result) + } + + @Test + fun `getIconForPlannerItem returns discussion icon for DISCUSSION_TOPIC type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.DISCUSSION_TOPIC) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_discussion, result) + } + + @Test + fun `getIconForPlannerItem returns discussion icon for SUB_ASSIGNMENT type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.SUB_ASSIGNMENT) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_discussion, result) + } + + @Test + fun `getIconForPlannerItem returns todo icon for PLANNER_NOTE type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.PLANNER_NOTE) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_todo, result) + } + + @Test + fun `getIconForPlannerItem returns calendar icon for unknown type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.ANNOUNCEMENT) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_calendar, result) + } + + // getDateTextForPlannerItem tests + @Test + fun `getDateTextForPlannerItem returns formatted time for PLANNER_NOTE with todoDate`() { + val plannable = createPlannable(todoDate = TEST_DATE.toApiString()) + val plannerItem = createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + plannable = plannable + ) + + every { DateHelper.getFormattedTime(context, TEST_DATE) } returns "2:30 PM" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("2:30 PM", result) + } + + @Test + fun `getDateTextForPlannerItem returns null for PLANNER_NOTE without todoDate`() { + val plannable = createPlannable(todoDate = null) + val plannerItem = createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + plannable = plannable + ) + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertNull(result) + } + + @Test + fun `getDateTextForPlannerItem returns all day text for all-day CALENDAR_EVENT`() { + val plannable = createPlannable( + startAt = TEST_DATE, + endAt = TEST_DATE, + allDay = true + ) + val plannerItem = createPlannerItem( + plannableType = PlannableType.CALENDAR_EVENT, + plannable = plannable + ) + + every { context.getString(R.string.widgetAllDay) } returns "All Day" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("All Day", result) + } + + @Test + fun `getDateTextForPlannerItem returns single time for CALENDAR_EVENT with same start and end`() { + val plannable = createPlannable( + startAt = TEST_DATE, + endAt = TEST_DATE, + allDay = false + ) + val plannerItem = createPlannerItem( + plannableType = PlannableType.CALENDAR_EVENT, + plannable = plannable + ) + + every { DateHelper.getFormattedTime(context, TEST_DATE) } returns "2:30 PM" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("2:30 PM", result) + } + + @Test + fun `getDateTextForPlannerItem returns time range for CALENDAR_EVENT with different times`() { + val plannable = createPlannable( + startAt = TEST_DATE, + endAt = TEST_DATE_2, + allDay = false + ) + val plannerItem = createPlannerItem( + plannableType = PlannableType.CALENDAR_EVENT, + plannable = plannable + ) + + every { DateHelper.getFormattedTime(context, TEST_DATE) } returns "2:30 PM" + every { DateHelper.getFormattedTime(context, TEST_DATE_2) } returns "3:30 PM" + every { context.getString(R.string.widgetFromTo, "2:30 PM", "3:30 PM") } returns "2:30 PM - 3:30 PM" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("2:30 PM - 3:30 PM", result) + } + + @Test + fun `getDateTextForPlannerItem returns null for CALENDAR_EVENT without dates`() { + val plannable = createPlannable( + startAt = null, + endAt = null + ) + val plannerItem = createPlannerItem( + plannableType = PlannableType.CALENDAR_EVENT, + plannable = plannable + ) + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertNull(result) + } + + @Test + fun `getDateTextForPlannerItem returns formatted time for ASSIGNMENT with dueAt`() { + val plannable = createPlannable(dueAt = TEST_DATE) + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + plannable = plannable + ) + + every { DateHelper.getFormattedTime(context, TEST_DATE) } returns "2:30 PM" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("2:30 PM", result) + } + + @Test + fun `getDateTextForPlannerItem returns null for ASSIGNMENT without dueAt`() { + val plannable = createPlannable(dueAt = null) + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + plannable = plannable + ) + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertNull(result) + } + + // getContextNameForPlannerItem tests + @Test + fun `getContextNameForPlannerItem returns User To-Do for PLANNER_NOTE without contextName`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + contextName = null + ) + + every { context.getString(R.string.userCalendarToDo) } returns "User To-Do" + + val result = plannerItem.getContextNameForPlannerItem(context, emptyList()) + + assertEquals("User To-Do", result) + } + + @Test + fun `getContextNameForPlannerItem returns course todo for PLANNER_NOTE with contextName`() { + val course = Course(id = 123L, courseCode = "CS101") + val plannerItem = createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + courseId = 123L, + contextName = "Computer Science" + ) + + every { context.getString(R.string.courseToDo, "CS101") } returns "CS101 To-Do" + + val result = plannerItem.getContextNameForPlannerItem(context, listOf(course)) + + assertEquals("CS101 To-Do", result) + } + + @Test + fun `getContextNameForPlannerItem returns course code for Course context`() { + val course = Course(id = 123L, courseCode = "CS101") + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + courseId = 123L + ) + + val result = plannerItem.getContextNameForPlannerItem(context, listOf(course)) + + assertEquals("CS101", result) + } + + @Test + fun `getContextNameForPlannerItem returns empty string for Course context without matching course`() { + val course = Course(id = 999L, courseCode = "CS101") + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + courseId = 123L + ) + + val result = plannerItem.getContextNameForPlannerItem(context, listOf(course)) + + assertEquals("", result) + } + + @Test + fun `getContextNameForPlannerItem returns contextName for non-Course context`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + userId = 456L, + contextName = "Personal" + ) + + val result = plannerItem.getContextNameForPlannerItem(context, emptyList()) + + assertEquals("Personal", result) + } + + // getTagForPlannerItem tests + @Test + fun `getTagForPlannerItem returns reply to topic for REPLY_TO_TOPIC tag`() { + val plannable = createPlannable(subAssignmentTag = Const.REPLY_TO_TOPIC) + val plannerItem = createPlannerItem(plannable = plannable) + + every { context.getString(R.string.reply_to_topic) } returns "Reply to Topic" + + val result = plannerItem.getTagForPlannerItem(context) + + assertEquals("Reply to Topic", result) + } + + @Test + fun `getTagForPlannerItem returns additional replies for REPLY_TO_ENTRY with count`() { + val details = PlannerItemDetails(replyRequiredCount = 3) + val plannable = createPlannable(subAssignmentTag = Const.REPLY_TO_ENTRY) + val plannerItem = createPlannerItem( + plannable = plannable, + plannableItemDetails = details + ) + + every { context.getString(R.string.additional_replies, 3) } returns "3 Additional Replies" + + val result = plannerItem.getTagForPlannerItem(context) + + assertEquals("3 Additional Replies", result) + } + + @Test + fun `getTagForPlannerItem returns null for REPLY_TO_ENTRY without count`() { + val plannable = createPlannable(subAssignmentTag = Const.REPLY_TO_ENTRY) + val plannerItem = createPlannerItem( + plannable = plannable, + plannableItemDetails = null + ) + + val result = plannerItem.getTagForPlannerItem(context) + + assertNull(result) + } + + @Test + fun `getTagForPlannerItem returns null for no subAssignmentTag`() { + val plannable = createPlannable(subAssignmentTag = null) + val plannerItem = createPlannerItem(plannable = plannable) + + val result = plannerItem.getTagForPlannerItem(context) + + assertNull(result) + } + + // isComplete tests + @Test + fun `isComplete returns true when plannerOverride markedComplete is true`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + plannerOverride = com.instructure.canvasapi2.models.PlannerOverride( + plannableType = PlannableType.ASSIGNMENT, + plannableId = 1L, + markedComplete = true + ) + ) + + val result = plannerItem.isComplete() + + assertEquals(true, result) + } + + @Test + fun `isComplete returns false when plannerOverride markedComplete is false`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + plannerOverride = com.instructure.canvasapi2.models.PlannerOverride( + plannableType = PlannableType.ASSIGNMENT, + plannableId = 1L, + markedComplete = false + ) + ) + + val result = plannerItem.isComplete() + + assertEquals(false, result) + } + + @Test + fun `isComplete returns true for ASSIGNMENT when submitted`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = true) + ) + + val result = plannerItem.isComplete() + + assertEquals(true, result) + } + + @Test + fun `isComplete returns false for ASSIGNMENT when not submitted`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ) + + val result = plannerItem.isComplete() + + assertEquals(false, result) + } + + @Test + fun `isComplete returns false for CALENDAR_EVENT without override`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.CALENDAR_EVENT) + + val result = plannerItem.isComplete() + + assertEquals(false, result) + } + + @Test + fun `isComplete returns true for QUIZ when submitted`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.QUIZ, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = true) + ) + + val result = plannerItem.isComplete() + + assertEquals(true, result) + } + + @Test + fun `isComplete returns false for QUIZ when not submitted`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.QUIZ, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ) + + val result = plannerItem.isComplete() + + assertEquals(false, result) + } + + @Test + fun `isComplete returns false for QUIZ with null submission state`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.QUIZ, + submissionState = null + ) + + val result = plannerItem.isComplete() + + assertEquals(false, result) + } + + @Test + fun `isComplete returns true for QUIZ with plannerOverride marked complete`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.QUIZ, + plannerOverride = com.instructure.canvasapi2.models.PlannerOverride( + plannableType = PlannableType.QUIZ, + plannableId = 1L, + markedComplete = true + ), + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ) + + val result = plannerItem.isComplete() + + assertEquals(true, result) + } + + // filterByToDoFilters tests + @Test + fun `filterByToDoFilters filters out PLANNER_NOTE when personalTodos is false`() { + val filters = createToDoFilterEntity(personalTodos = false) + val items = listOf( + createPlannerItem(plannableType = PlannableType.PLANNER_NOTE), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT), + createPlannerItem(plannableType = PlannableType.CALENDAR_EVENT) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(2, result.size) + assertEquals(PlannableType.ASSIGNMENT, result[0].plannableType) + assertEquals(PlannableType.CALENDAR_EVENT, result[1].plannableType) + } + + @Test + fun `filterByToDoFilters includes PLANNER_NOTE when personalTodos is true`() { + val filters = createToDoFilterEntity(personalTodos = true) + val items = listOf( + createPlannerItem(plannableType = PlannableType.PLANNER_NOTE), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(2, result.size) + assertEquals(PlannableType.PLANNER_NOTE, result[0].plannableType) + } + + @Test + fun `filterByToDoFilters filters out CALENDAR_EVENT when calendarEvents is false`() { + val filters = createToDoFilterEntity(calendarEvents = false) + val items = listOf( + createPlannerItem(plannableType = PlannableType.CALENDAR_EVENT), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT), + createPlannerItem(plannableType = PlannableType.PLANNER_NOTE) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(2, result.size) + assertEquals(PlannableType.ASSIGNMENT, result[0].plannableType) + assertEquals(PlannableType.PLANNER_NOTE, result[1].plannableType) + } + + @Test + fun `filterByToDoFilters includes CALENDAR_EVENT when calendarEvents is true`() { + val filters = createToDoFilterEntity(calendarEvents = true) + val items = listOf( + createPlannerItem(plannableType = PlannableType.CALENDAR_EVENT), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(2, result.size) + assertEquals(PlannableType.CALENDAR_EVENT, result[0].plannableType) + } + + @Test + fun `filterByToDoFilters filters out completed items when showCompleted is false`() { + val filters = createToDoFilterEntity(showCompleted = false) + val items = listOf( + createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = true) + ), + createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ), + createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + plannerOverride = com.instructure.canvasapi2.models.PlannerOverride( + plannableType = PlannableType.ASSIGNMENT, + plannableId = 1L, + markedComplete = true + ) + ) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(1, result.size) + assertEquals(false, result[0].isComplete()) + } + + @Test + fun `filterByToDoFilters includes completed items when showCompleted is true`() { + val filters = createToDoFilterEntity(showCompleted = true) + val items = listOf( + createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = true) + ), + createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(2, result.size) + } + + @Test + fun `filterByToDoFilters filters to favorite courses only when favoriteCourses is true`() { + val filters = createToDoFilterEntity(favoriteCourses = true) + val courses = listOf( + Course(id = 1L, isFavorite = true), + Course(id = 2L, isFavorite = false), + Course(id = 3L, isFavorite = true) + ) + val items = listOf( + createPlannerItem(plannableType = PlannableType.ASSIGNMENT, courseId = 1L), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT, courseId = 2L), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT, courseId = 3L) + ) + + val result = items.filterByToDoFilters(filters, courses) + + assertEquals(2, result.size) + assertEquals(1L, result[0].courseId) + assertEquals(3L, result[1].courseId) + } + + @Test + fun `filterByToDoFilters includes all courses when favoriteCourses is false`() { + val filters = createToDoFilterEntity(favoriteCourses = false) + val courses = listOf( + Course(id = 1L, isFavorite = true), + Course(id = 2L, isFavorite = false) + ) + val items = listOf( + createPlannerItem(plannableType = PlannableType.ASSIGNMENT, courseId = 1L), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT, courseId = 2L) + ) + + val result = items.filterByToDoFilters(filters, courses) + + assertEquals(2, result.size) + } + + @Test + fun `filterByToDoFilters includes items with no matching course when favoriteCourses is true`() { + val filters = createToDoFilterEntity(favoriteCourses = true) + val courses = listOf( + Course(id = 1L, isFavorite = true) + ) + val items = listOf( + createPlannerItem(plannableType = PlannableType.ASSIGNMENT, courseId = 1L), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT, courseId = 999L) + ) + + val result = items.filterByToDoFilters(filters, courses) + + // Both items should be included: favorite course and item with no matching course + assertEquals(2, result.size) + assertEquals(1L, result[0].courseId) + assertEquals(999L, result[1].courseId) + } + + @Test + fun `filterByToDoFilters applies multiple filters correctly`() { + val filters = createToDoFilterEntity( + personalTodos = false, + calendarEvents = false, + showCompleted = false, + favoriteCourses = true + ) + val courses = listOf( + Course(id = 1L, isFavorite = true), + Course(id = 2L, isFavorite = false) + ) + val items = listOf( + createPlannerItem(plannableType = PlannableType.PLANNER_NOTE), + createPlannerItem(plannableType = PlannableType.CALENDAR_EVENT), + createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + courseId = 1L, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = true) + ), + createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + courseId = 2L, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ), + createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + courseId = 1L, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ) + ) + + val result = items.filterByToDoFilters(filters, courses) + + assertEquals(1, result.size) + assertEquals(PlannableType.ASSIGNMENT, result[0].plannableType) + assertEquals(1L, result[0].courseId) + assertEquals(false, result[0].isComplete()) + } + + @Test + fun `filterByToDoFilters returns empty list when all items are filtered out`() { + val filters = createToDoFilterEntity( + personalTodos = false, + calendarEvents = false + ) + val items = listOf( + createPlannerItem(plannableType = PlannableType.PLANNER_NOTE), + createPlannerItem(plannableType = PlannableType.CALENDAR_EVENT) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(0, result.size) + } + + @Test + fun `filterByToDoFilters returns all items when no filters are applied`() { + val filters = createToDoFilterEntity( + personalTodos = true, + calendarEvents = true, + showCompleted = true, + favoriteCourses = false + ) + val items = listOf( + createPlannerItem(plannableType = PlannableType.PLANNER_NOTE), + createPlannerItem(plannableType = PlannableType.CALENDAR_EVENT), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(3, result.size) + } + + @Test + fun `filterByToDoFilters handles empty input list`() { + val filters = createToDoFilterEntity() + + val result = emptyList().filterByToDoFilters(filters, emptyList()) + + assertEquals(0, result.size) + } + + @Test + fun `filterByToDoFilters includes null courseId items when filtering favorites`() { + val filters = createToDoFilterEntity(favoriteCourses = true) + val courses = listOf(Course(id = 1L, isFavorite = true)) + val items = listOf( + createPlannerItem(plannableType = PlannableType.ASSIGNMENT, courseId = null), + createPlannerItem(plannableType = PlannableType.ASSIGNMENT, courseId = 1L) + ) + + val result = items.filterByToDoFilters(filters, courses) + + // Both items should be included: null courseId and favorite course + assertEquals(2, result.size) + assertEquals(null, result[0].courseId) + assertEquals(1L, result[1].courseId) + } + + @Test + fun `filterByToDoFilters uses plannable courseId for PLANNER_NOTE when item courseId is null`() { + val filters = createToDoFilterEntity(favoriteCourses = true) + val courses = listOf( + Course(id = 1L, isFavorite = true), + Course(id = 2L, isFavorite = false) + ) + val items = listOf( + createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + courseId = null, + plannable = createPlannable(courseId = 1L) + ), + createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + courseId = null, + plannable = createPlannable(courseId = 2L) + ) + ) + + val result = items.filterByToDoFilters(filters, courses) + + // Only PLANNER_NOTE with favorite course (via plannable.courseId) should be included + assertEquals(1, result.size) + assertEquals(1L, result[0].plannable.courseId) + } + + @Test + fun `filterByToDoFilters prefers item courseId over plannable courseId for PLANNER_NOTE`() { + val filters = createToDoFilterEntity(favoriteCourses = true) + val courses = listOf( + Course(id = 1L, isFavorite = true), + Course(id = 2L, isFavorite = false) + ) + val items = listOf( + createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + courseId = 2L, // item.courseId is set (non-favorite) + plannable = createPlannable(courseId = 1L) // plannable.courseId is favorite + ) + ) + + val result = items.filterByToDoFilters(filters, courses) + + // Should use item.courseId (2L) which is not favorite, so item is filtered out + assertEquals(0, result.size) + } + + @Test + fun `filterByToDoFilters handles DISCUSSION_TOPIC completion`() { + val filters = createToDoFilterEntity(showCompleted = false) + val items = listOf( + createPlannerItem( + plannableType = PlannableType.DISCUSSION_TOPIC, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = true) + ), + createPlannerItem( + plannableType = PlannableType.DISCUSSION_TOPIC, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(1, result.size) + assertEquals(false, result[0].isComplete()) + } + + @Test + fun `filterByToDoFilters handles SUB_ASSIGNMENT completion`() { + val filters = createToDoFilterEntity(showCompleted = false) + val items = listOf( + createPlannerItem( + plannableType = PlannableType.SUB_ASSIGNMENT, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = true) + ), + createPlannerItem( + plannableType = PlannableType.SUB_ASSIGNMENT, + submissionState = com.instructure.canvasapi2.models.SubmissionState(submitted = false) + ) + ) + + val result = items.filterByToDoFilters(filters, emptyList()) + + assertEquals(1, result.size) + assertEquals(false, result[0].isComplete()) + } + + @Test + fun `getUrl returns full URL for calendar event`() { + val plannerItem = createPlannerItem( + courseId = 123, + plannableType = PlannableType.CALENDAR_EVENT, + plannableId = 456 + ) + + val result = plannerItem.getUrl(apiPrefs) + + assertEquals("https://example.instructure.com/courses/123/calendar_events/456", result) + } + + @Test + fun `getUrl returns full URL for planner note`() { + val plannerItem = createPlannerItem( + userId = 1, + plannableType = PlannableType.PLANNER_NOTE, + plannableId = 789 + ) + + val result = plannerItem.getUrl(apiPrefs) + + assertEquals("https://example.instructure.com/todos/789", result) + } + + @Test + fun `getUrl returns htmlUrl for assignment when htmlUrl is set`() { + val plannerItem = createPlannerItem( + courseId = 123, + plannableType = PlannableType.ASSIGNMENT, + plannableId = 456, + htmlUrl = "https://example.instructure.com/courses/123/assignments/456" + ) + + val result = plannerItem.getUrl(apiPrefs) + + assertEquals("https://example.instructure.com/courses/123/assignments/456", result) + } + + @Test + fun `getUrl returns htmlUrl for quiz when htmlUrl is set`() { + val plannerItem = createPlannerItem( + courseId = 123, + plannableType = PlannableType.QUIZ, + plannableId = 456, + htmlUrl = "https://example.instructure.com/courses/123/quizzes/456" + ) + + val result = plannerItem.getUrl(apiPrefs) + + assertEquals("https://example.instructure.com/courses/123/quizzes/456", result) + } + + @Test + fun `getUrl returns htmlUrl for discussion when htmlUrl is set`() { + val plannerItem = createPlannerItem( + courseId = 123, + plannableType = PlannableType.DISCUSSION_TOPIC, + plannableId = 456, + htmlUrl = "https://example.instructure.com/courses/123/discussion_topics/456" + ) + + val result = plannerItem.getUrl(apiPrefs) + + assertEquals("https://example.instructure.com/courses/123/discussion_topics/456", result) + } + + @Test + fun `getUrl returns empty string when htmlUrl is null for assignment`() { + val plannerItem = createPlannerItem( + courseId = 123, + plannableType = PlannableType.ASSIGNMENT, + plannableId = 456, + htmlUrl = null + ) + + val result = plannerItem.getUrl(apiPrefs) + + assertEquals("", result) + } + + @Test + fun `getUrl handles calendar event with group context`() { + val plannerItem = createPlannerItem( + groupId = 789, + plannableType = PlannableType.CALENDAR_EVENT, + plannableId = 456 + ) + + val result = plannerItem.getUrl(apiPrefs) + + assertEquals("https://example.instructure.com/groups/789/calendar_events/456", result) + } + + @Test + fun `getUrl handles planner note with relative path correctly`() { + val plannerItem = createPlannerItem( + userId = 1, + plannableType = PlannableType.PLANNER_NOTE, + plannableId = 123 + ) + + val result = plannerItem.getUrl(apiPrefs) + + assertEquals("https://example.instructure.com/todos/123", result) + } + + @Test + fun `getUrl does not double-prepend domain when htmlUrl already contains domain`() { + val plannerItem = createPlannerItem( + courseId = 123, + plannableType = PlannableType.ASSIGNMENT, + plannableId = 456, + htmlUrl = "https://example.instructure.com/courses/123/assignments/456" + ) + + val result = plannerItem.getUrl(apiPrefs) + + assertEquals("https://example.instructure.com/courses/123/assignments/456", result) + } + + // Helper functions to create test objects with default values + private fun createToDoFilterEntity( + personalTodos: Boolean = true, + calendarEvents: Boolean = true, + showCompleted: Boolean = true, + favoriteCourses: Boolean = false + ): com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity { + return com.instructure.pandautils.room.appdatabase.entities.ToDoFilterEntity( + userDomain = "test.instructure.com", + userId = 123L, + personalTodos = personalTodos, + calendarEvents = calendarEvents, + showCompleted = showCompleted, + favoriteCourses = favoriteCourses + ) + } + private fun createPlannable( + id: Long = 1L, + title: String = "Test", + courseId: Long? = null, + groupId: Long? = null, + userId: Long? = null, + pointsPossible: Double? = null, + dueAt: Date? = null, + assignmentId: Long? = null, + todoDate: String? = null, + startAt: Date? = null, + endAt: Date? = null, + details: String? = null, + allDay: Boolean? = null, + subAssignmentTag: String? = null + ): Plannable { + return Plannable( + id = id, + title = title, + courseId = courseId, + groupId = groupId, + userId = userId, + pointsPossible = pointsPossible, + dueAt = dueAt, + assignmentId = assignmentId, + todoDate = todoDate, + startAt = startAt, + endAt = endAt, + details = details, + allDay = allDay, + subAssignmentTag = subAssignmentTag + ) + } + + private fun createPlannerItem( + plannableId: Long = 1L, + plannableType: PlannableType = PlannableType.ASSIGNMENT, + plannable: Plannable = createPlannable(id = plannableId), + courseId: Long? = null, + groupId: Long? = null, + userId: Long? = null, + contextName: String? = null, + plannableItemDetails: PlannerItemDetails? = null, + submissionState: com.instructure.canvasapi2.models.SubmissionState? = null, + plannerOverride: com.instructure.canvasapi2.models.PlannerOverride? = null, + htmlUrl: String? = null + ): PlannerItem { + return PlannerItem( + courseId = courseId, + groupId = groupId, + userId = userId, + contextType = if (courseId != null) "Course" else null, + contextName = contextName, + plannableType = plannableType, + plannable = plannable, + plannableDate = Date(), + htmlUrl = htmlUrl, + submissionState = submissionState, + newActivity = null, + plannerOverride = plannerOverride, + plannableItemDetails = plannableItemDetails + ) + } +} \ No newline at end of file